diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt index deda62b31735..7af4ceb00f41 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt @@ -31,6 +31,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore @@ -108,6 +109,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( private val emailManager: EmailManager, private val inContextDataStore: EmailProtectionInContextDataStore, private val recentInstallChecker: EmailProtectionInContextRecentInstallChecker, + private val loginDeduplicator: AutofillLoginDeduplicator, ) : AutofillJavascriptInterface { override var callback: Callback? = null @@ -209,10 +211,13 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( val credentials = filterRequestedSubtypes(request, matches) - if (credentials.isEmpty()) { + val dedupedCredentials = loginDeduplicator.deduplicate(url, credentials) + Timber.v("Original autofill credentials list size: %d, after de-duping: %d", credentials.size, dedupedCredentials.size) + + if (dedupedCredentials.isEmpty()) { callback?.noCredentialsAvailable(url) } else { - callback?.onCredentialsAvailableToInject(url, credentials, triggerType) + callback?.onCredentialsAvailableToInject(url, dedupedCredentials, triggerType) } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationBestMatchFinder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationBestMatchFinder.kt new file mode 100644 index 000000000000..b81f37a30948 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationBestMatchFinder.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.deduper.AutofillDeduplicationMatchTypeDetector.MatchType.NotAMatch +import com.duckduckgo.autofill.impl.deduper.AutofillDeduplicationMatchTypeDetector.MatchType.PartialMatch +import com.duckduckgo.autofill.impl.deduper.AutofillDeduplicationMatchTypeDetector.MatchType.PerfectMatch +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AutofillDeduplicationBestMatchFinder { + + fun findBestMatch( + originalUrl: String, + logins: List, + ): LoginCredentials? +} + +@ContributesBinding(AppScope::class) +class RealAutofillDeduplicationBestMatchFinder @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, + private val matchTypeDetector: AutofillDeduplicationMatchTypeDetector, +) : AutofillDeduplicationBestMatchFinder { + + override fun findBestMatch( + originalUrl: String, + logins: List, + ): LoginCredentials? { + // perfect matches are those where the subdomain and e-tld+1 match + val perfectMatches = mutableListOf() + + // partial matches are those where only e-tld+1 matches + val partialMatches = mutableListOf() + + // non-matches are those where neither subdomain nor e-tld+1 match + val nonMatches = mutableListOf() + + categoriseEachLogin(logins, originalUrl, perfectMatches, partialMatches, nonMatches) + + if (perfectMatches.isEmpty() && partialMatches.isEmpty() && nonMatches.isEmpty()) { + return null + } + + return if (perfectMatches.isNotEmpty()) { + bestPerfectMatch(perfectMatches) + } else if (partialMatches.isNotEmpty()) { + bestPartialMatch(partialMatches) + } else { + bestNonMatch(nonMatches) + } + } + + private fun categoriseEachLogin( + logins: List, + originalUrl: String, + perfectMatches: MutableList, + partialMatches: MutableList, + nonMatches: MutableList, + ) { + logins.forEach { + when (matchTypeDetector.detectMatchType(originalUrl, it)) { + PerfectMatch -> perfectMatches.add(it) + PartialMatch -> partialMatches.add(it) + NotAMatch -> nonMatches.add(it) + } + } + } + + private fun bestPerfectMatch(perfectMatches: List): LoginCredentials { + return perfectMatches.sortedWith(AutofillDeduplicationLoginComparator()).first() + } + + private fun bestPartialMatch(partialMatches: MutableList): LoginCredentials { + return partialMatches.sortedWith(AutofillDeduplicationLoginComparator()).first() + } + + private fun bestNonMatch(nonMatches: MutableList): LoginCredentials { + return nonMatches.sortedWith(AutofillDeduplicationLoginComparator()).first() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationMatchTypeDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationMatchTypeDetector.kt new file mode 100644 index 000000000000..74161b0d0c83 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationMatchTypeDetector.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.deduper.AutofillDeduplicationMatchTypeDetector.MatchType +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AutofillDeduplicationMatchTypeDetector { + + fun detectMatchType( + originalUrl: String, + login: LoginCredentials, + ): MatchType + + sealed interface MatchType { + object PerfectMatch : MatchType + object PartialMatch : MatchType + object NotAMatch : MatchType + } +} + +@ContributesBinding(AppScope::class) +class RealAutofillDeduplicationMatchTypeDetector @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, +) : AutofillDeduplicationMatchTypeDetector { + + override fun detectMatchType( + originalUrl: String, + login: LoginCredentials, + ): MatchType { + val visitedSiteParts = urlMatcher.extractUrlPartsForAutofill(originalUrl) + val savedSiteParts = urlMatcher.extractUrlPartsForAutofill(login.domain) + + if (!urlMatcher.matchingForAutofill(visitedSiteParts, savedSiteParts)) { + return MatchType.NotAMatch + } + + return if (visitedSiteParts.subdomain == savedSiteParts.subdomain) { + MatchType.PerfectMatch + } else { + MatchType.PartialMatch + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationUsernameAndPasswordMatcher.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationUsernameAndPasswordMatcher.kt new file mode 100644 index 000000000000..35de6a2e1cf6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillDeduplicationUsernameAndPasswordMatcher.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AutofillDeduplicationUsernameAndPasswordMatcher { + + fun groupDuplicateCredentials(logins: List): Map, List> +} + +@ContributesBinding(AppScope::class) +class RealAutofillDeduplicationUsernameAndPasswordMatcher @Inject constructor() : AutofillDeduplicationUsernameAndPasswordMatcher { + + override fun groupDuplicateCredentials(logins: List): Map, List> { + return logins.groupBy { it.username to it.password } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillLoginDeduplicator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillLoginDeduplicator.kt new file mode 100644 index 000000000000..b47d1abc296d --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/AutofillLoginDeduplicator.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AutofillLoginDeduplicator { + + fun deduplicate( + originalUrl: String, + logins: List, + ): List +} + +@ContributesBinding(AppScope::class) +class RealAutofillLoginDeduplicator @Inject constructor( + private val usernamePasswordMatcher: AutofillDeduplicationUsernameAndPasswordMatcher, + private val bestMatchFinder: AutofillDeduplicationBestMatchFinder, +) : AutofillLoginDeduplicator { + + override fun deduplicate( + originalUrl: String, + logins: List, + ): List { + val dedupedLogins = mutableListOf() + + val groups = usernamePasswordMatcher.groupDuplicateCredentials(logins) + groups.forEach { + val bestMatchForGroup = bestMatchFinder.findBestMatch(originalUrl, it.value) + if (bestMatchForGroup != null) { + dedupedLogins.add(bestMatchForGroup) + } + } + + return dedupedLogins + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplication.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplication.kt new file mode 100644 index 000000000000..0cdb53c5bb38 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplication.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials + +class AutofillDeduplicationLoginComparator : Comparator { + override fun compare( + o1: LoginCredentials, + o2: LoginCredentials, + ): Int { + val lastModifiedComparison = compareLastModified(o1.lastUpdatedMillis, o2.lastUpdatedMillis) + if (lastModifiedComparison != 0) return lastModifiedComparison + + // last updated matches, fallback to domain + return compareDomains(o1.domain, o2.domain) + } + + private fun compareLastModified( + lastModified1: Long?, + lastModified2: Long?, + ): Int { + if (lastModified1 == null && lastModified2 == null) return 0 + + if (lastModified1 == null) return -1 + if (lastModified2 == null) return 1 + return lastModified2.compareTo(lastModified1) + } + + private fun compareDomains( + domain1: String?, + domain2: String?, + ): Int { + if (domain1 == null && domain2 == null) return 0 + if (domain1 == null) return -1 + if (domain2 == null) return 1 + return domain1.compareTo(domain2) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt index f03265bcbc23..fbe99a7e18e5 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt @@ -28,6 +28,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillStoredBackJavascriptInterface.UrlProvider +import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster @@ -75,6 +76,7 @@ class AutofillStoredBackJavascriptInterfaceTest { private val inContextDataStore: EmailProtectionInContextDataStore = mock() private val recentInstallChecker: EmailProtectionInContextRecentInstallChecker = mock() private val testWebView = WebView(getApplicationContext()) + private val loginDeduplicator: AutofillLoginDeduplicator = NoopDeduplicator() private lateinit var testee: AutofillStoredBackJavascriptInterface private val testCallback = TestCallback() @@ -99,6 +101,7 @@ class AutofillStoredBackJavascriptInterfaceTest { emailManager = emailManager, inContextDataStore = inContextDataStore, recentInstallChecker = recentInstallChecker, + loginDeduplicator = loginDeduplicator, ) testee.callback = testCallback testee.webView = testWebView @@ -390,4 +393,8 @@ class AutofillStoredBackJavascriptInterfaceTest { // no-op } } + + private class NoopDeduplicator : AutofillLoginDeduplicator { + override fun deduplicate(originalUrl: String, logins: List): List = logins + } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillLoginDeduplicatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillLoginDeduplicatorTest.kt new file mode 100644 index 000000000000..84a7de98da12 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillLoginDeduplicatorTest.kt @@ -0,0 +1,165 @@ +package com.duckduckgo.autofill.impl.deduper + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class RealAutofillLoginDeduplicatorTest { + private val urlMatcher = AutofillDomainNameUrlMatcher(TestUrlUnicodeNormalizer()) + private val matchTypeDetector = RealAutofillDeduplicationMatchTypeDetector(urlMatcher) + private val testee = RealAutofillLoginDeduplicator( + usernamePasswordMatcher = RealAutofillDeduplicationUsernameAndPasswordMatcher(), + bestMatchFinder = RealAutofillDeduplicationBestMatchFinder( + urlMatcher = urlMatcher, + matchTypeDetector = matchTypeDetector, + ), + ) + + @Test + fun whenEmptyListInThenEmptyListOut() = runTest { + val result = testee.deduplicate("example.com", emptyList()) + assertTrue(result.isEmpty()) + } + + @Test + fun whenSingleEntryInThenSingleEntryReturned() { + val inputList = listOf( + aLogin("domain", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + } + + @Test + fun whenEntriesCompletelyUnrelatedThenNoDeduplication() { + val inputList = listOf( + aLogin("domain_A", "username_A", "password_A"), + aLogin("domain_B", "username_B", "password_B"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(2, result.size) + assertNotNull(result.find { it.domain == "domain_A" }) + assertNotNull(result.find { it.domain == "domain_B" }) + } + + @Test + fun whenEntriesShareUsernameAndPasswordButNotDomainThenDeduped() { + val inputList = listOf( + aLogin("foo.com", "username", "password"), + aLogin("bar.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + } + + @Test + fun whenEntriesShareDomainAndUsernameButNotPasswordThenNoDeduplication() { + val inputList = listOf( + aLogin("example.com", "username", "123"), + aLogin("example.com", "username", "xyz"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(2, result.size) + assertNotNull(result.find { it.password == "123" }) + assertNotNull(result.find { it.password == "xyz" }) + } + + @Test + fun whenEntriesShareDomainAndPasswordButNotUsernameThenNoDeduplication() { + val inputList = listOf( + aLogin("example.com", "user_A", "password"), + aLogin("example.com", "user_B", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(2, result.size) + assertNotNull(result.find { it.username == "user_A" }) + assertNotNull(result.find { it.username == "user_B" }) + } + + @Test + fun whenEntriesShareMultipleCredentialsWhichArePerfectDomainMatchesThenDeduped() { + val inputList = listOf( + aLogin("example.com", "username", "password"), + aLogin("example.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + } + + @Test + fun whenEntriesShareMultipleCredentialsWhichArePartialDomainMatchesThenDeduped() { + val inputList = listOf( + aLogin("a.example.com", "username", "password"), + aLogin("b.example.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + } + + @Test + fun whenEntriesShareMultipleCredentialsWhichAreNotDomainMatchesThenDeduped() { + val inputList = listOf( + aLogin("foo.com", "username", "password"), + aLogin("bar.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + } + + @Test + fun whenEntriesShareCredentialsAcrossPerfectAndPartialMatchesThenDedupedToPerfectMatch() { + val inputList = listOf( + aLogin("example.com", "username", "password"), + aLogin("a.example.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + assertNotNull(result.find { it.domain == "example.com" }) + } + + @Test + fun whenEntriesShareCredentialsAcrossPerfectAndNonDomainMatchesThenDedupedToPerfectMatch() { + val inputList = listOf( + aLogin("example.com", "username", "password"), + aLogin("bar.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + assertNotNull(result.find { it.domain == "example.com" }) + } + + @Test + fun whenEntriesShareCredentialsAcrossPartialAndNonDomainMatchesThenDedupedToPerfectMatch() { + val inputList = listOf( + aLogin("a.example.com", "username", "password"), + aLogin("bar.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + assertNotNull(result.find { it.domain == "a.example.com" }) + } + + @Test + fun whenEntriesShareCredentialsAcrossPerfectAndPartialAndNonDomainMatchesThenDedupedToPerfectMatch() { + val inputList = listOf( + aLogin("a.example.com", "username", "password"), + aLogin("example.com", "username", "password"), + aLogin("bar.com", "username", "password"), + ) + val result = testee.deduplicate("example.com", inputList) + assertEquals(1, result.size) + assertNotNull(result.find { it.domain == "example.com" }) + } + + private fun aLogin(domain: String, username: String, password: String): LoginCredentials { + return LoginCredentials(username = username, password = password, domain = domain) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillMatchTypeDetectorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillMatchTypeDetectorTest.kt new file mode 100644 index 000000000000..19a35dcb698c --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealAutofillMatchTypeDetectorTest.kt @@ -0,0 +1,53 @@ +package com.duckduckgo.autofill.impl.deduper + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.deduper.AutofillDeduplicationMatchTypeDetector.MatchType +import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealAutofillMatchTypeDetectorTest { + private val testee = RealAutofillDeduplicationMatchTypeDetector(AutofillDomainNameUrlMatcher(TestUrlUnicodeNormalizer())) + + @Test + fun whenExactUrlMatchThenTypeIsPerfectMatch() { + val result = testee.detectMatchType("example.com", creds("example.com")) + result.assertIsPerfectMatch() + } + + @Test + fun whenSubdomainMatchOnSavedSiteThenTypeIsPartialMatch() { + val result = testee.detectMatchType("example.com", creds("subdomain.example.com")) + result.assertIsPartialMatch() + } + + @Test + fun whenSubdomainMatchOnVisitedSiteThenTypeIsPartialMatch() { + val result = testee.detectMatchType("subdomain.example.com", creds("example.com")) + result.assertIsPartialMatch() + } + + @Test + fun whenSubdomainMatchOnBothVisitedAndSavedSiteThenTypeIsPerfectMatch() { + val result = testee.detectMatchType("subdomain.example.com", creds("subdomain.example.com")) + result.assertIsPerfectMatch() + } + + @Test + fun whenNoETldPlusOneMatchNotAMatch() { + val result = testee.detectMatchType("foo.com", creds("example.com")) + result.assertNotAMatch() + } + + private fun MatchType.assertIsPerfectMatch() = assertTrue(this is MatchType.PerfectMatch) + private fun MatchType.assertIsPartialMatch() = assertTrue(this is MatchType.PartialMatch) + private fun MatchType.assertNotAMatch() = assertTrue(this is MatchType.NotAMatch) + + private fun creds(domain: String): LoginCredentials { + return LoginCredentials(domain = domain, username = "", password = "") + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorBestMatchFinderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorBestMatchFinderTest.kt new file mode 100644 index 000000000000..8d7495c1096b --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorBestMatchFinderTest.kt @@ -0,0 +1,88 @@ +package com.duckduckgo.autofill.impl.deduper + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealLoginDeduplicatorBestMatchFinderTest { + + private val urlMatcher = AutofillDomainNameUrlMatcher(TestUrlUnicodeNormalizer()) + private val matchTypeDetector = RealAutofillDeduplicationMatchTypeDetector(urlMatcher) + private val testee = RealAutofillDeduplicationBestMatchFinder( + urlMatcher = urlMatcher, + matchTypeDetector = matchTypeDetector, + ) + + @Test + fun whenEmptyListThenNoBestMatchFound() { + assertNull(testee.findBestMatch("", emptyList())) + } + + @Test + fun whenSinglePerfectMatchThenThatIsReturnedAsBestMatch() { + val input = listOf( + LoginCredentials(id = 0, domain = "example.com", username = "username", password = "password"), + ) + val result = testee.findBestMatch("example.com", input) + assertNotNull(result) + } + + @Test + fun whenMultiplePerfectMatchesMostRecentlyModifiedIsReturned() { + val input = listOf( + creds("example.com", 1000), + creds("example.com", 2000), + ) + val result = testee.findBestMatch("example.com", input) + assertEquals(2000L, result!!.lastUpdatedMillis) + } + + @Test + fun whenMultiplePartialMatchesWithSameTimestampThenDomainAlphabeticallyFirstReturned() { + val input = listOf( + creds("a.example.com", 2000), + creds("b.example.com", 2000), + ) + val result = testee.findBestMatch("example.com", input) + assertEquals("a.example.com", result!!.domain) + } + + @Test + fun whenSingleNonMatchThenReturnedAsBestMatch() { + val input = listOf( + creds("not-a-match.com", 2000), + ) + val result = testee.findBestMatch("example.com", input) + assertEquals("not-a-match.com", result!!.domain) + } + + @Test + fun whenMultipleNonMatchesThenMostRecentlyModifiedIsReturned() { + val input = listOf( + creds("not-a-match.com", 2000), + creds("also-not-a-match.com", 1000), + ) + val result = testee.findBestMatch("example.com", input) + assertEquals("not-a-match.com", result!!.domain) + } + + @Test + fun whenMatchesFromAllTypesThenMatchInPerfectReturnedRegardlessOfTimestamps() { + val input = listOf( + creds("perfect-match.com", 1000), + creds("imperfect-match.com", 3000), + creds("not-a-match.com", 2000), + ) + val result = testee.findBestMatch("perfect-match.com", input) + assertEquals("perfect-match.com", result!!.domain) + } + + private fun creds(domain: String, lastModified: Long?): LoginCredentials { + return LoginCredentials(domain = domain, lastUpdatedMillis = lastModified, username = "", password = "") + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorUsernameAndPasswordMatcherTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorUsernameAndPasswordMatcherTest.kt new file mode 100644 index 000000000000..08ec6cf0d8f2 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginDeduplicatorUsernameAndPasswordMatcherTest.kt @@ -0,0 +1,70 @@ +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import org.junit.Assert.* +import org.junit.Test + +class RealLoginDeduplicatorUsernameAndPasswordMatcherTest { + private val testee = RealAutofillDeduplicationUsernameAndPasswordMatcher() + + @Test + fun whenEmptyListInThenEmptyListOut() { + val input = emptyList() + val output = testee.groupDuplicateCredentials(input) + assertTrue(output.isEmpty()) + } + + @Test + fun whenSingleEntryInThenSingleEntryOut() { + val input = listOf( + creds("username", "password"), + ) + val output = testee.groupDuplicateCredentials(input) + assertEquals(1, output.size) + } + + @Test + fun whenMultipleEntriesWithNoDuplicationAtAllThenNumberOfGroupsReturnedMatchesNumberOfEntriesInputted() { + val input = listOf( + creds("username_a", "password_x"), + creds("username_b", "password_y"), + creds("username_c", "password_z"), + ) + val output = testee.groupDuplicateCredentials(input) + assertEquals(3, output.size) + } + + @Test + fun whenEntriesMatchOnUsernameButNotPasswordThenNotGrouped() { + val input = listOf( + creds("username", "password_x"), + creds("username", "password_y"), + ) + val output = testee.groupDuplicateCredentials(input) + assertEquals(2, output.size) + } + + @Test + fun whenEntriesMatchOnPasswordButNotUsernameThenNotGrouped() { + val input = listOf( + creds("username_a", "password"), + creds("username_b", "password"), + ) + val output = testee.groupDuplicateCredentials(input) + assertEquals(2, output.size) + } + + @Test + fun whenEntriesMatchOnUsernameAndPasswordThenGrouped() { + val input = listOf( + creds("username", "password"), + creds("username", "password"), + ) + val output = testee.groupDuplicateCredentials(input) + assertEquals(1, output.size) + } + + private fun creds(username: String, password: String): LoginCredentials { + return LoginCredentials(username = username, password = password, domain = "domain") + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplicationTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplicationTest.kt new file mode 100644 index 000000000000..6d436ade1f48 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deduper/RealLoginSorterForDeduplicationTest.kt @@ -0,0 +1,80 @@ +package com.duckduckgo.autofill.impl.deduper + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import org.junit.Assert.* +import org.junit.Test + +class RealLoginSorterForDeduplicationTest { + + private val testee = AutofillDeduplicationLoginComparator() + + @Test + fun whenFirstLoginIsNewerThenReturnNegative() { + val login1 = creds(lastUpdated = 2000, domain = null) + val login2 = creds(lastUpdated = 1000, domain = null) + assertTrue(testee.compare(login1, login2) < 0) + } + + @Test + fun whenSecondLoginIsNewerThenReturnPositive() { + val login1 = creds(lastUpdated = 1000, domain = null) + val login2 = creds(lastUpdated = 2000, domain = null) + assertTrue(testee.compare(login1, login2) > 0) + } + + @Test + fun whenFirstLoginHasNoLastModifiedTimestampThenReturnsNegative() { + val login1 = creds(lastUpdated = null, domain = null) + val login2 = creds(lastUpdated = 2000, domain = null) + assertTrue(testee.compare(login1, login2) < 0) + } + + @Test + fun whenSecondLoginHasNoLastModifiedTimestampThenReturnsPositive() { + val login1 = creds(lastUpdated = 1000, domain = null) + val login2 = creds(lastUpdated = null, domain = null) + assertTrue(testee.compare(login1, login2) > 0) + } + + @Test + fun whenLastModifiedTimesEqualAndFirstLoginDomainShouldBeSortedFirstThenReturnsNegative() { + val login1 = creds(lastUpdated = 1000, domain = "example.com") + val login2 = creds(lastUpdated = 1000, domain = "site.com") + assertTrue(testee.compare(login1, login2) < 0) + } + + @Test + fun whenLastModifiedTimesEqualAndSecondLoginDomainShouldBeSortedFirstThenReturnsNegative() { + val login1 = creds(lastUpdated = 1000, domain = "site.com") + val login2 = creds(lastUpdated = 1000, domain = "example.com") + assertTrue(testee.compare(login1, login2) > 0) + } + + @Test + fun whenLastModifiedTimesEqualAndDomainsEqualThenReturns0() { + val login1 = creds(lastUpdated = 1000, domain = "example.com") + val login2 = creds(lastUpdated = 1000, domain = "example.com") + assertEquals(0, testee.compare(login1, login2)) + } + + @Test + fun whenLastModifiedDatesMissingAndDomainMissingThenReturns0() { + val login1 = creds(lastUpdated = null, domain = null) + val login2 = creds(lastUpdated = null, domain = null) + assertEquals(0, testee.compare(login1, login2)) + } + + @Test + fun whenLoginsSameLastUpdatedTimeThenReturn0() { + val login1 = creds(lastUpdated = 1000, domain = null) + val login2 = creds(lastUpdated = 1000, domain = null) + assertEquals(0, testee.compare(login1, login2)) + } + + private fun creds( + lastUpdated: Long?, + domain: String? = "example.com", + ): LoginCredentials { + return LoginCredentials(id = 0, lastUpdatedMillis = lastUpdated, domain = domain, username = "username", password = "password") + } +}