mirror of
https://github.com/bitwarden/android.git
synced 2026-06-10 16:46:10 -05:00
Add fill assist data layer
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package com.x8bit.bitwarden.data.autofill.datasource.disk
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
|
||||
/**
|
||||
* Disk source for persisting fill-assist targeting rules per server.
|
||||
*
|
||||
* All operations are scoped by [serverUrl] (the fill-assist CDN base URL provided by the server
|
||||
* config), so multiple accounts on the same server share one cached copy of the rules while
|
||||
* accounts on different servers remain independent.
|
||||
*/
|
||||
interface FillAssistDiskSource {
|
||||
|
||||
/**
|
||||
* Returns the cached [FillAssistRules] for [serverUrl], or null if none are stored.
|
||||
*/
|
||||
fun getFillAssistRules(serverUrl: String): FillAssistRules?
|
||||
|
||||
/**
|
||||
* Stores [rules] for [serverUrl], or removes the entry when [rules] is null.
|
||||
*/
|
||||
fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?)
|
||||
|
||||
/**
|
||||
* Returns the last known content hash (CID) for [serverUrl], or null if none is stored.
|
||||
*/
|
||||
fun getLastKnownCid(serverUrl: String): String?
|
||||
|
||||
/**
|
||||
* Stores [cid] for [serverUrl], or removes the entry when [cid] is null.
|
||||
*/
|
||||
fun storeLastKnownCid(serverUrl: String, cid: String?)
|
||||
|
||||
/**
|
||||
* Returns the epoch-millisecond timestamp of the last successful fetch for [serverUrl],
|
||||
* or null if never fetched.
|
||||
*/
|
||||
fun getLastFetchTimestamp(serverUrl: String): Long?
|
||||
|
||||
/**
|
||||
* Stores [timestamp] for [serverUrl], or removes the entry when [timestamp] is null.
|
||||
*/
|
||||
fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.x8bit.bitwarden.data.autofill.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
import com.bitwarden.data.datasource.disk.BaseDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// Bump this constant in two cases:
|
||||
// 1. The parsing logic changes in a way that invalidates previously cached results.
|
||||
// 2. EXPECTED_SCHEMA_MAJOR in FillAssistManagerImpl is updated to support a new schema major.
|
||||
// Without bumping this, the staleness check would skip re-downloading data that was previously
|
||||
// rejected for an unsupported schema — meaning the app would never pick up the new rules.
|
||||
// On the next app launch after a bump, all stored fill-assist data is cleared and re-downloaded.
|
||||
private const val CURRENT_CACHE_VERSION = 0
|
||||
|
||||
private const val FILL_ASSIST_CACHE_VERSION_KEY = "fillAssistCacheVersion"
|
||||
private const val FILL_ASSIST_RULES_KEY = "fillAssistRules"
|
||||
private const val FILL_ASSIST_CID_KEY = "fillAssistLastCid"
|
||||
private const val FILL_ASSIST_TIMESTAMP_KEY = "fillAssistLastFetchTimestamp"
|
||||
|
||||
/**
|
||||
* Primary implementation of [FillAssistDiskSource].
|
||||
*/
|
||||
class FillAssistDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseDiskSource(sharedPreferences),
|
||||
FillAssistDiskSource {
|
||||
|
||||
init {
|
||||
performMigrationIfNeeded()
|
||||
}
|
||||
|
||||
override fun getFillAssistRules(serverUrl: String): FillAssistRules? =
|
||||
getString(FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl))
|
||||
?.let { json.decodeFromStringOrNull(it) }
|
||||
|
||||
override fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) {
|
||||
putString(
|
||||
FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl),
|
||||
rules?.let { json.encodeToString(it) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLastKnownCid(serverUrl: String): String? =
|
||||
getString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl))
|
||||
|
||||
override fun storeLastKnownCid(serverUrl: String, cid: String?) {
|
||||
putString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl), cid)
|
||||
}
|
||||
|
||||
override fun getLastFetchTimestamp(serverUrl: String): Long? =
|
||||
getLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl))
|
||||
|
||||
override fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) {
|
||||
putLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl), timestamp)
|
||||
}
|
||||
|
||||
private fun performMigrationIfNeeded() {
|
||||
if (getInt(FILL_ASSIST_CACHE_VERSION_KEY) == CURRENT_CACHE_VERSION) return
|
||||
clearAllData()
|
||||
}
|
||||
|
||||
private fun clearAllData() {
|
||||
removeWithPrefix(FILL_ASSIST_RULES_KEY)
|
||||
removeWithPrefix(FILL_ASSIST_CID_KEY)
|
||||
removeWithPrefix(FILL_ASSIST_TIMESTAMP_KEY)
|
||||
putInt(FILL_ASSIST_CACHE_VERSION_KEY, CURRENT_CACHE_VERSION)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.x8bit.bitwarden.data.autofill.di
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.time.Clock
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides fill-assist dependencies.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object FillAssistModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesFillAssistDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): FillAssistDiskSource =
|
||||
FillAssistDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesFillAssistManager(
|
||||
fillAssistService: FillAssistService,
|
||||
fillAssistDiskSource: FillAssistDiskSource,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
serverConfigRepository: ServerConfigRepository,
|
||||
clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): FillAssistManager =
|
||||
FillAssistManagerImpl(
|
||||
fillAssistService = fillAssistService,
|
||||
fillAssistDiskSource = fillAssistDiskSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
serverConfigRepository = serverConfigRepository,
|
||||
clock = clock,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.autofill.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
|
||||
/**
|
||||
* Manages fetching and caching fill-assist targeting rules.
|
||||
*
|
||||
* Rules are scoped per server (the fill-assist CDN URL from server config), so multiple accounts
|
||||
* on the same server share one cached copy.
|
||||
*/
|
||||
interface FillAssistManager {
|
||||
/**
|
||||
* Triggers a background sync if no sync is currently running. The sync fetches and persists
|
||||
* fill-assist rules when the feature flag is enabled and cached data is stale.
|
||||
*/
|
||||
fun syncIfNecessary()
|
||||
|
||||
/**
|
||||
* Returns the last successfully cached [FillAssistRules] for the active server, or null if
|
||||
* none exist.
|
||||
*/
|
||||
fun getFillAssistRules(): FillAssistRules?
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.x8bit.bitwarden.data.autofill.manager
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import java.time.Clock
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules.SelectorClause
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import timber.log.Timber
|
||||
|
||||
private const val CURRENT_FORMS_VERSION = "v0"
|
||||
private const val EXPECTED_SCHEMA_MAJOR = "0"
|
||||
|
||||
/** Re-fetch interval in milliseconds (6 hours, matching the browser implementation). */
|
||||
private const val UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
// Matches [attr='value'] and [attr="value"] attribute selectors.
|
||||
private val ATTRIBUTE_REGEX = Regex("""\[([a-zA-Z\-]+)=['"](.*?)['"]]""")
|
||||
|
||||
// Matches the CSS #id shorthand (e.g. "input#oid", "select#state").
|
||||
// Used as a fallback when [id='value'] is absent.
|
||||
private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""")
|
||||
|
||||
// Extracts the leading tag name from a selector (e.g. "input", "select", "form").
|
||||
private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""")
|
||||
|
||||
/**
|
||||
* Primary implementation of [FillAssistManager].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class FillAssistManagerImpl(
|
||||
private val fillAssistService: FillAssistService,
|
||||
private val fillAssistDiskSource: FillAssistDiskSource,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val serverConfigRepository: ServerConfigRepository,
|
||||
private val clock: Clock,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : FillAssistManager {
|
||||
|
||||
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
|
||||
private val ioScope = CoroutineScope(dispatcherManager.io)
|
||||
private var syncJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
serverConfigRepository.serverConfigStateFlow
|
||||
.filterNotNull()
|
||||
.onEach { syncIfNecessary() }
|
||||
.launchIn(unconfinedScope)
|
||||
}
|
||||
|
||||
override fun syncIfNecessary() {
|
||||
if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return
|
||||
val serverUrl = serverConfigRepository.serverConfigStateFlow.value
|
||||
?.serverData?.environment?.fillAssistRulesUrl ?: return
|
||||
val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L
|
||||
if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return
|
||||
if (!syncJob.isCompleted) return
|
||||
syncJob = ioScope.launch { sync(serverUrl) }
|
||||
}
|
||||
|
||||
private suspend fun sync(serverUrl: String) = runCatching {
|
||||
// Always fetch the manifest — it is the CID staleness check.
|
||||
val manifest = fillAssistService
|
||||
.getManifest(url = serverUrl.trimEnd('/') + "/manifest.json")
|
||||
.getOrThrow()
|
||||
|
||||
val versionEntry = manifest.maps?.forms?.get(CURRENT_FORMS_VERSION)
|
||||
?: error("Version $CURRENT_FORMS_VERSION not found in manifest")
|
||||
val cid = versionEntry.cid
|
||||
?: error("No CID for version $CURRENT_FORMS_VERSION in manifest")
|
||||
|
||||
if (versionEntry.deprecated == true) {
|
||||
Timber.w("Fill-assist forms $CURRENT_FORMS_VERSION is deprecated")
|
||||
}
|
||||
|
||||
// CID check: data on the server is unchanged — update the timestamp and skip download.
|
||||
if (cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) {
|
||||
fillAssistDiskSource.storeLastFetchTimestamp(
|
||||
serverUrl = serverUrl,
|
||||
timestamp = clock.millis(),
|
||||
)
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
val formsUrl = serverUrl.trimEnd('/') + "/" +
|
||||
(versionEntry.filename ?: "forms.$CURRENT_FORMS_VERSION.json")
|
||||
|
||||
val forms = fillAssistService
|
||||
.getForms(formsUrl = formsUrl)
|
||||
.getOrThrow()
|
||||
|
||||
val schemaMajor = forms.schemaVersion?.substringBefore('.')
|
||||
if (schemaMajor != EXPECTED_SCHEMA_MAJOR) {
|
||||
Timber.w("Unsupported fill-assist schema version: ${forms.schemaVersion}")
|
||||
fillAssistDiskSource.storeLastFetchTimestamp(
|
||||
serverUrl = serverUrl,
|
||||
timestamp = clock.millis(),
|
||||
)
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
val rules = parseForms(forms)
|
||||
fillAssistDiskSource.storeFillAssistRules(serverUrl = serverUrl, rules = rules)
|
||||
fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = cid)
|
||||
fillAssistDiskSource.storeLastFetchTimestamp(
|
||||
serverUrl = serverUrl,
|
||||
timestamp = clock.millis(),
|
||||
)
|
||||
}.also { result ->
|
||||
result.onFailure { Timber.w(it, "Fill-assist sync failed") }
|
||||
}
|
||||
|
||||
override fun getFillAssistRules(): FillAssistRules? {
|
||||
val environment =
|
||||
serverConfigRepository.serverConfigStateFlow.value?.serverData?.environment
|
||||
val serverUrl = environment?.fillAssistRulesUrl ?: return null
|
||||
return fillAssistDiskSource.getFillAssistRules(serverUrl = serverUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// region CSS parser
|
||||
|
||||
private fun parseForms(forms: FillAssistFormsJson): FillAssistRules {
|
||||
val hostRules = forms.hosts
|
||||
?.mapNotNull { (hostname, hostEntry) -> hostEntry?.let { hostname to parseHostEntry(it) } }
|
||||
?.filter { (_, rules) -> rules.isNotEmpty() }
|
||||
?.toMap()
|
||||
.orEmpty()
|
||||
return FillAssistRules(hostRules = hostRules)
|
||||
}
|
||||
|
||||
private fun parseHostEntry(
|
||||
hostEntry: FillAssistFormsJson.HostEntryJson,
|
||||
): List<FillAssistRules.HostRule> {
|
||||
val allForms = buildList {
|
||||
addAll(hostEntry.forms.orEmpty())
|
||||
hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms.orEmpty()) }
|
||||
}.distinct()
|
||||
|
||||
return buildFieldsByCategory(allForms).map { (category, fields) ->
|
||||
FillAssistRules.HostRule(
|
||||
category = category,
|
||||
fields = fields.mapValues { (_, selectors) -> selectors.distinct() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFieldsByCategory(
|
||||
forms: List<FillAssistFormsJson.FormJson>,
|
||||
): Map<String, MutableMap<String, MutableList<SelectorClause>>> {
|
||||
val result = mutableMapOf<String, MutableMap<String, MutableList<SelectorClause>>>()
|
||||
forms.mapNotNull { form -> form.category?.let { form to it } }
|
||||
.forEach { (form, category) ->
|
||||
val parsedFields = form.fields.orEmpty()
|
||||
.mapValues { (_, elem) -> parseCompositeSelectorArray(elem) }
|
||||
.filterValues { it.isNotEmpty() }
|
||||
.takeIf { it.isNotEmpty() } ?: return@forEach
|
||||
val categoryFields = result.getOrPut(category) { mutableMapOf() }
|
||||
parsedFields.forEach { (fieldKey, selectors) ->
|
||||
categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseCompositeSelectorArray(element: JsonElement): List<SelectorClause> {
|
||||
if (element !is JsonArray) return emptyList()
|
||||
val result = mutableListOf<SelectorClause>()
|
||||
for (item in element) {
|
||||
when (item) {
|
||||
is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) }
|
||||
is JsonArray -> item
|
||||
.filterIsInstance<JsonPrimitive>()
|
||||
.mapNotNull { parseSingleSelector(it.content) }
|
||||
.forEach { result.add(it) }
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal fun parseSingleSelector(selector: String): SelectorClause? {
|
||||
// For shadow DOM / iframe selectors (>>>), extract the last segment — the actual target
|
||||
// element. Android's autofill framework may expose these elements via htmlInfo when they
|
||||
// are reachable (e.g. open shadow roots), so we parse their attributes for matching.
|
||||
val effective = if (selector.contains(">>>")) {
|
||||
selector.substringAfterLast(">>>").trim()
|
||||
} else {
|
||||
selector
|
||||
}
|
||||
if (effective.trimStart().startsWith(".")) return null
|
||||
|
||||
val tag = TAG_REGEX.find(effective)?.groupValues?.get(1)
|
||||
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var type: String? = null
|
||||
var role: String? = null
|
||||
|
||||
ATTRIBUTE_REGEX.findAll(effective).forEach { match ->
|
||||
val attrName = match.groupValues[1]
|
||||
val attrValue = match.groupValues[2]
|
||||
when (attrName) {
|
||||
"id" -> id = attrValue
|
||||
"name" -> name = attrValue
|
||||
"type" -> type = attrValue
|
||||
"role" -> role = attrValue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract id from #shorthand (e.g. input#oid) when not present as [id='...'].
|
||||
if (id == null) {
|
||||
id = ID_SHORTHAND_REGEX.find(effective)?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
return SelectorClause(tag = tag, id = id, name = name, type = type, role = role)
|
||||
}
|
||||
|
||||
// endregion
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.x8bit.bitwarden.data.autofill.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Parsed, storage-ready representation of fill-assist targeting rules for all known hosts.
|
||||
*
|
||||
* @property hostRules Map of hostname (with optional port) to a list of [HostRule] entries.
|
||||
* Multiple [HostRule] entries per host are possible when different pages define different forms.
|
||||
*/
|
||||
@Serializable
|
||||
data class FillAssistRules(
|
||||
val hostRules: Map<String, List<HostRule>>,
|
||||
) {
|
||||
/**
|
||||
* Describes one parsed form for a host. Combines host-level and pathname-level forms into a
|
||||
* single pooled representation so the consumer does not need to know the current URL path.
|
||||
*
|
||||
* @property category The form's purpose category (e.g. "account-login", "payment-card").
|
||||
* @property fields Map of field key (e.g. "username", "password") to a list of
|
||||
* [SelectorClause] alternatives. The first clause that matches a view node is used.
|
||||
*/
|
||||
@Serializable
|
||||
data class HostRule(
|
||||
val category: String,
|
||||
val fields: Map<String, List<SelectorClause>>,
|
||||
)
|
||||
|
||||
/**
|
||||
* A single parsed CSS selector expressing HTML attribute constraints for matching a view node
|
||||
* via [android.view.ViewStructure.HtmlInfo]. All non-null fields are AND constraints.
|
||||
*
|
||||
* @property tag The HTML tag name (e.g. "input", "select").
|
||||
* @property id The value of the element's [id] attribute.
|
||||
* @property name The value of the element's [name] attribute.
|
||||
* @property type The value of the element's [type] attribute (e.g. "password", "text").
|
||||
* @property role The value of the element's [role] attribute.
|
||||
*/
|
||||
@Serializable
|
||||
data class SelectorClause(
|
||||
val tag: String?,
|
||||
val id: String?,
|
||||
val name: String?,
|
||||
val type: String?,
|
||||
val role: String?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.x8bit.bitwarden.data.autofill.datasource.disk
|
||||
|
||||
import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FillAssistDiskSourceTest {
|
||||
private val fakeSharedPreferences = FakeSharedPreferences()
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
private val diskSource = FillAssistDiskSourceImpl(
|
||||
sharedPreferences = fakeSharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `migration clears all fill-assist data across all servers`() {
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
|
||||
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc")
|
||||
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = 123L)
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_2, rules = FILL_ASSIST_RULES)
|
||||
|
||||
// Trigger migration by writing a stale version — clears data for all servers.
|
||||
fakeSharedPreferences.edit()
|
||||
.putInt("bwPreferencesStorage:fillAssistCacheVersion", -1)
|
||||
.apply()
|
||||
val clearedDiskSource = FillAssistDiskSourceImpl(
|
||||
sharedPreferences = fakeSharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
assertNull(clearedDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
|
||||
assertNull(clearedDiskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
|
||||
assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeFillAssistRules and getFillAssistRules round-trip`() {
|
||||
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
|
||||
assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = null)
|
||||
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data is scoped per server, one server does not affect another`() {
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
|
||||
|
||||
assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_2))
|
||||
assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeLastKnownCid and getLastKnownCid round-trip`() {
|
||||
val cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db"
|
||||
assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = cid)
|
||||
assertEquals(cid, diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = null)
|
||||
assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeLastFetchTimestamp and getLastFetchTimestamp round-trip`() {
|
||||
val timestamp = 1716307262956L
|
||||
assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = timestamp)
|
||||
assertEquals(timestamp, diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
|
||||
|
||||
diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = null)
|
||||
assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration preserves data when cache version is current`() {
|
||||
diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES)
|
||||
diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc")
|
||||
|
||||
// New instance with the same preferences — version already set to current by first init.
|
||||
val sameDiskSource = FillAssistDiskSourceImpl(
|
||||
sharedPreferences = fakeSharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertEquals(FILL_ASSIST_RULES, sameDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1))
|
||||
assertEquals("sha256:abc", sameDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1))
|
||||
}
|
||||
}
|
||||
|
||||
private const val SERVER_URL_1 = "https://fill-assist.example.com"
|
||||
private const val SERVER_URL_2 = "https://fill-assist.other.com"
|
||||
|
||||
private val FILL_ASSIST_RULES = FillAssistRules(
|
||||
hostRules = mapOf(
|
||||
"example.com" to listOf(
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-login",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "email",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
"password" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = null,
|
||||
name = "pass",
|
||||
type = "password",
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,708 @@
|
||||
package com.x8bit.bitwarden.data.autofill.manager
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.manager.model.FlagKey
|
||||
import com.bitwarden.data.datasource.disk.model.ServerConfig
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.network.model.ConfigResponseJson
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.model.FillAssistManifestJson
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
private const val BASE_URL = "https://fill-assist.example.com"
|
||||
private const val MANIFEST_URL = "$BASE_URL/manifest.json"
|
||||
private const val FORMS_URL = "$BASE_URL/forms.v0.json"
|
||||
private const val CID = "sha256:abc123"
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2026-01-01T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
/** A timestamp far in the past, ensuring the timestamp check never skips network calls. */
|
||||
private const val STALE_TIMESTAMP = 0L
|
||||
|
||||
class FillAssistManagerTest {
|
||||
|
||||
private val featureFlagManager: FeatureFlagManager = mockk {
|
||||
every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns true
|
||||
}
|
||||
|
||||
private val serverConfigRepository: ServerConfigRepository = mockk {
|
||||
every { serverConfigStateFlow } returns MutableStateFlow(SERVER_CONFIG)
|
||||
}
|
||||
|
||||
private val fillAssistService: FillAssistService = mockk {
|
||||
coEvery { getManifest(url = MANIFEST_URL) } returns Result.success(MANIFEST)
|
||||
coEvery { getForms(formsUrl = FORMS_URL) } returns Result.success(FORMS_V1)
|
||||
}
|
||||
|
||||
private val fillAssistDiskSource: FillAssistDiskSource = mockk {
|
||||
every { getLastFetchTimestamp(BASE_URL) } returns STALE_TIMESTAMP
|
||||
every { getLastKnownCid(BASE_URL) } returns null
|
||||
every { getFillAssistRules(BASE_URL) } returns null
|
||||
every { storeFillAssistRules(any(), any()) } just runs
|
||||
every { storeLastKnownCid(any(), any()) } just runs
|
||||
every { storeLastFetchTimestamp(any(), any()) } just runs
|
||||
}
|
||||
|
||||
private val manager = FillAssistManagerImpl(
|
||||
fillAssistService = fillAssistService,
|
||||
fillAssistDiskSource = fillAssistDiskSource,
|
||||
featureFlagManager = featureFlagManager,
|
||||
serverConfigRepository = serverConfigRepository,
|
||||
clock = FIXED_CLOCK,
|
||||
dispatcherManager = FakeDispatcherManager(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
// serverConfigStateFlow replays its current value on subscription, triggering
|
||||
// syncIfNecessary() during construction. Clear call counts for a clean test slate.
|
||||
clearMocks(fillAssistService, fillAssistDiskSource, answers = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync returns success and does nothing when feature flag is disabled`() = runTest {
|
||||
every {
|
||||
featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)
|
||||
} returns false
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync returns success and does nothing when fillAssistRulesUrl is null`() = runTest {
|
||||
every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null)
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync skips all network calls when timestamp is fresh`() = runTest {
|
||||
every {
|
||||
fillAssistDiskSource.getLastFetchTimestamp(BASE_URL)
|
||||
} returns FIXED_CLOCK.millis() - (6 * 60 * 60 * 1000L - 1)
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 0) { fillAssistService.getManifest(any()) }
|
||||
coVerify(exactly = 0) { fillAssistService.getForms(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync skips forms download and updates timestamp when CID is unchanged`() = runTest {
|
||||
every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns CID
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 1) { fillAssistService.getManifest(url = MANIFEST_URL) }
|
||||
coVerify(exactly = 0) { fillAssistService.getForms(any()) }
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
|
||||
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync re-fetches forms when CID changes`() = runTest {
|
||||
every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns "sha256:old"
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
coVerify(exactly = 1) { fillAssistService.getForms(formsUrl = FORMS_URL) }
|
||||
verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) }
|
||||
verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) }
|
||||
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync does not store data when manifest fetch fails`() = runTest {
|
||||
coEvery {
|
||||
fillAssistService.getManifest(any())
|
||||
} returns Result.failure(RuntimeException("network error"))
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) }
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeLastFetchTimestamp(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync does not store rules or cid when schemaVersion major is unsupported`() = runTest {
|
||||
coEvery { fillAssistService.getForms(any()) } returns Result.success(
|
||||
FORMS_V1.copy(schemaVersion = "1.0.0"),
|
||||
)
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
|
||||
verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) }
|
||||
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync happy path stores rules, cid, and timestamp`() = runTest {
|
||||
manager.syncIfNecessary()
|
||||
|
||||
verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) }
|
||||
verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) }
|
||||
verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync pools forms from multiple pathnames under the same host`() = runTest {
|
||||
coEvery {
|
||||
fillAssistService.getForms(any())
|
||||
} returns Result.success(FORMS_V1_MULTI_PATHNAME)
|
||||
|
||||
val rulesSlot = slot<FillAssistRules>()
|
||||
every {
|
||||
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
|
||||
} just runs
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
assertEquals(EXPECTED_RULES_MULTI_PATHNAME, rulesSlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync pools host-level forms and pathname forms under the same host`() = runTest {
|
||||
coEvery {
|
||||
fillAssistService.getForms(any())
|
||||
} returns Result.success(FORMS_V1_HOST_AND_PATHNAME)
|
||||
|
||||
val rulesSlot = slot<FillAssistRules>()
|
||||
every {
|
||||
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
|
||||
} just runs
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
assertEquals(EXPECTED_RULES_HOST_AND_PATHNAME, rulesSlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync merges forms with the same category from different pathnames into one HostRule`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
fillAssistService.getForms(any())
|
||||
} returns Result.success(FORMS_V1_SAME_CATEGORY_PATHNAMES)
|
||||
|
||||
val rulesSlot = slot<FillAssistRules>()
|
||||
every {
|
||||
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
|
||||
} just runs
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
assertEquals(EXPECTED_RULES_MERGED_CATEGORY, rulesSlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync deduplicates selector clauses within a merged category`() = runTest {
|
||||
coEvery {
|
||||
fillAssistService.getForms(any())
|
||||
} returns Result.success(FORMS_V1_DUPLICATE_SELECTORS)
|
||||
|
||||
val rulesSlot = slot<FillAssistRules>()
|
||||
every {
|
||||
fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot))
|
||||
} just runs
|
||||
|
||||
manager.syncIfNecessary()
|
||||
|
||||
assertEquals(EXPECTED_RULES_DEDUPLICATED_SELECTORS, rulesSlot.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFillAssistRules delegates to disk source`() {
|
||||
val expected = FillAssistRules(hostRules = emptyMap())
|
||||
every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns expected
|
||||
assertEquals(expected, manager.getFillAssistRules())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFillAssistRules returns null when disk source has no data`() {
|
||||
every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns null
|
||||
assertNull(manager.getFillAssistRules())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFillAssistRules returns null when server URL is not configured`() {
|
||||
every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null)
|
||||
assertNull(manager.getFillAssistRules())
|
||||
}
|
||||
|
||||
// region CSS parser
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts tag and id shorthand`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "oid",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("input#oid"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts name attribute`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = null,
|
||||
name = "p",
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("input[name='p']"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts compound selector with id shorthand and name`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "password",
|
||||
name = "password",
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("input#password[name='password']"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts role attribute`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "form",
|
||||
id = null,
|
||||
name = null,
|
||||
type = null,
|
||||
role = "search",
|
||||
),
|
||||
parseSingleSelector("form[role='search']"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts last segment of shadow DOM selector`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "field",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("div#container >>> input#field"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector extracts last segment of multi-level shadow DOM selector`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
name = "password",
|
||||
id = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("div#form-container >>> form > div >>> input[name='password']"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector returns null for pure class selector`() {
|
||||
assertNull(parseSingleSelector(".loginForm"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseSingleSelector handles select element`() {
|
||||
assertEquals(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "select",
|
||||
id = "state",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
parseSingleSelector("select#state"),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
private val MANIFEST = FillAssistManifestJson(
|
||||
buildId = "local-build",
|
||||
timestamp = null,
|
||||
gitSha = null,
|
||||
maps = FillAssistManifestJson.MapsJson(
|
||||
forms = mapOf(
|
||||
"v0" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v0.json",
|
||||
cid = CID,
|
||||
schema = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val FORMS_V1 = FillAssistFormsJson(
|
||||
schemaVersion = "0.1.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(
|
||||
listOf(JsonPrimitive("input#user")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
pathnames = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Host with two pathnames — both forms must appear in the stored rules.
|
||||
private val FORMS_V1_MULTI_PATHNAME = FillAssistFormsJson(
|
||||
schemaVersion = "0.1.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = null,
|
||||
pathnames = mapOf(
|
||||
"/login" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
"/register" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-creation",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#email"))),
|
||||
"newPassword" to JsonArray(listOf(JsonPrimitive("input#new-pass"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val EXPECTED_RULES_MULTI_PATHNAME = FillAssistRules(
|
||||
hostRules = mapOf(
|
||||
"example.com" to listOf(
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-login",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "user",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
"password" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "pass",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-creation",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "email",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
"newPassword" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "new-pass",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Host with both top-level forms and pathname forms — both must appear in the stored rules.
|
||||
private val FORMS_V1_HOST_AND_PATHNAME = FillAssistFormsJson(
|
||||
schemaVersion = "0.1.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
pathnames = mapOf(
|
||||
"/checkout" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "payment-card",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"cardNumber" to JsonArray(listOf(JsonPrimitive("input#card-num"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val EXPECTED_RULES_HOST_AND_PATHNAME = FillAssistRules(
|
||||
hostRules = mapOf(
|
||||
"example.com" to listOf(
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-login",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "user",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FillAssistRules.HostRule(
|
||||
category = "payment-card",
|
||||
fields = mapOf(
|
||||
"cardNumber" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "card-num",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Two pathnames both define account-login — must be merged into one HostRule.
|
||||
private val FORMS_V1_SAME_CATEGORY_PATHNAMES = FillAssistFormsJson(
|
||||
schemaVersion = "0.1.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = null,
|
||||
pathnames = mapOf(
|
||||
"/login" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
"/signin" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#email"))),
|
||||
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val EXPECTED_RULES_MERGED_CATEGORY = FillAssistRules(
|
||||
hostRules = mapOf(
|
||||
"example.com" to listOf(
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-login",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "user",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "email",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
"password" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "pass",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Two pathnames define the same selector — the duplicate must be removed.
|
||||
private val FORMS_V1_DUPLICATE_SELECTORS = FillAssistFormsJson(
|
||||
schemaVersion = "0.1.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = null,
|
||||
pathnames = mapOf(
|
||||
"/login" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
"/other-login" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = null,
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val EXPECTED_RULES_DEDUPLICATED_SELECTORS = FillAssistRules(
|
||||
hostRules = mapOf(
|
||||
"example.com" to listOf(
|
||||
FillAssistRules.HostRule(
|
||||
category = "account-login",
|
||||
fields = mapOf(
|
||||
"username" to listOf(
|
||||
FillAssistRules.SelectorClause(
|
||||
tag = "input",
|
||||
id = "user",
|
||||
name = null,
|
||||
type = null,
|
||||
role = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val SERVER_CONFIG = ServerConfig(
|
||||
lastSync = 0L,
|
||||
serverData = ConfigResponseJson(
|
||||
type = null,
|
||||
version = null,
|
||||
gitHash = null,
|
||||
server = null,
|
||||
environment = ConfigResponseJson.EnvironmentJson(
|
||||
cloudRegion = null,
|
||||
vaultUrl = null,
|
||||
apiUrl = null,
|
||||
identityUrl = null,
|
||||
notificationsUrl = null,
|
||||
ssoUrl = null,
|
||||
fillAssistRulesUrl = BASE_URL,
|
||||
),
|
||||
featureStates = null,
|
||||
communication = null,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user