mirror of
https://github.com/bitwarden/android.git
synced 2026-06-09 16:21:19 -05:00
Compare commits
3 Commits
main
...
PM-37255/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83720f369 | ||
|
|
5fde28716a | ||
|
|
7fcae79136 |
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
|
||||
import android.app.Application
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
@@ -20,6 +21,9 @@ import javax.inject.Inject
|
||||
class BitwardenApplication : Application() {
|
||||
// Inject classes here that must be triggered on startup but are not otherwise consumed by
|
||||
// other callers.
|
||||
@Inject
|
||||
lateinit var fillAssistManager: FillAssistManager
|
||||
|
||||
@Inject
|
||||
lateinit var logsManager: LogsManager
|
||||
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
|
||||
import com.bitwarden.network.model.BitwardenServiceClientConfig
|
||||
import com.bitwarden.network.service.ConfigService
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.bitwarden.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
|
||||
@@ -32,6 +33,12 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlatformNetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesFillAssistService(
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
): FillAssistService = bitwardenServiceClient.fillAssistService
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesConfigService(
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.bitwarden.vault.DecryptCipherListResult
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
|
||||
@@ -78,6 +79,7 @@ class VaultSyncManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val fillAssistManager: FillAssistManager,
|
||||
private val userLogoutManager: UserLogoutManager,
|
||||
private val userStateManager: UserStateManager,
|
||||
private val vaultLockManager: VaultLockManager,
|
||||
@@ -340,6 +342,7 @@ class VaultSyncManagerImpl(
|
||||
lastSyncTime = clock.instant(),
|
||||
)
|
||||
vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse)
|
||||
fillAssistManager.syncIfNecessary()
|
||||
val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true
|
||||
SyncVaultDataResult.Success(itemsAvailable = itemsAvailable)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
@@ -224,6 +225,7 @@ object VaultManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVaultSyncManager(
|
||||
fillAssistManager: FillAssistManager,
|
||||
syncService: SyncService,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
@@ -237,6 +239,7 @@ object VaultManagerModule {
|
||||
pushManager: PushManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): VaultSyncManager = VaultSyncManagerImpl(
|
||||
fillAssistManager = fillAssistManager,
|
||||
syncService = syncService,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
authDiskSource = authDiskSource,
|
||||
|
||||
@@ -8104,6 +8104,7 @@ class AuthRepositoryTest {
|
||||
identityUrl = "mockIdentityUrl",
|
||||
notificationsUrl = "mockNotificationsUrl",
|
||||
ssoUrl = "mockSsoUrl",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = emptyMap(),
|
||||
communication = null,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
|
||||
identityUrl = null,
|
||||
notificationsUrl = null,
|
||||
ssoUrl = null,
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = null,
|
||||
communication = ConfigResponseJson.CommunicationJson(
|
||||
|
||||
@@ -319,6 +319,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"dummy-boolean" to JsonPrimitive(true),
|
||||
|
||||
@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"duo-redirect" to JsonPrimitive(true),
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
@@ -143,7 +144,12 @@ class VaultSyncManagerTest {
|
||||
every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow
|
||||
}
|
||||
|
||||
private val fillAssistManager: FillAssistManager = mockk {
|
||||
every { syncIfNecessary() } just runs
|
||||
}
|
||||
|
||||
private val vaultSyncManager: VaultSyncManager = VaultSyncManagerImpl(
|
||||
fillAssistManager = fillAssistManager,
|
||||
syncService = syncService,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
|
||||
@@ -262,6 +262,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"dummy-boolean" to JsonPrimitive(true),
|
||||
|
||||
@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"duo-redirect" to JsonPrimitive(true),
|
||||
|
||||
@@ -107,6 +107,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"duo-redirect" to JsonPrimitive(true),
|
||||
|
||||
@@ -161,6 +161,7 @@ private val SERVER_CONFIG = ServerConfig(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"duo-redirect" to JsonPrimitive(true),
|
||||
@@ -185,6 +186,7 @@ private val CONFIG_RESPONSE_JSON = ConfigResponseJson(
|
||||
identityUrl = "http://localhost:33656",
|
||||
notificationsUrl = "http://localhost:61840",
|
||||
ssoUrl = "http://localhost:51822",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"duo-redirect" to JsonPrimitive(true),
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
|
||||
import com.bitwarden.network.service.DigitalAssetLinkService
|
||||
import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
import com.bitwarden.network.service.IdentityService
|
||||
@@ -107,6 +108,11 @@ interface BitwardenServiceClient {
|
||||
*/
|
||||
val eventService: EventService
|
||||
|
||||
/**
|
||||
* Provides access to the Fill-Assist service.
|
||||
*/
|
||||
val fillAssistService: FillAssistService
|
||||
|
||||
/**
|
||||
* Provides access to the Folder service.
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.DownloadServiceImpl
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.EventServiceImpl
|
||||
import com.bitwarden.network.service.FillAssistService
|
||||
import com.bitwarden.network.service.FillAssistServiceImpl
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.FolderServiceImpl
|
||||
import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
@@ -155,6 +157,12 @@ internal class BitwardenServiceClientImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override val fillAssistService: FillAssistService by lazy {
|
||||
FillAssistServiceImpl(
|
||||
api = retrofits.createStaticRetrofit().create(),
|
||||
)
|
||||
}
|
||||
|
||||
override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy {
|
||||
HaveIBeenPwnedServiceImpl(
|
||||
api = retrofits
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.bitwarden.network.api
|
||||
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.model.FillAssistManifestJson
|
||||
import com.bitwarden.network.model.NetworkResult
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Url
|
||||
|
||||
/**
|
||||
* Defines endpoints for retrieving fill-assist targeting rules from the fill-assist service.
|
||||
* Uses [Url] to support the dynamic base URL provided by server config at runtime.
|
||||
*/
|
||||
internal interface FillAssistApi {
|
||||
/**
|
||||
* Fetches the fill-assist manifest from the given [url].
|
||||
*/
|
||||
@GET
|
||||
suspend fun getManifest(
|
||||
@Url url: String,
|
||||
): NetworkResult<FillAssistManifestJson>
|
||||
|
||||
/**
|
||||
* Fetches and decodes the forms rules file from [url].
|
||||
*/
|
||||
@GET
|
||||
suspend fun getForms(
|
||||
@Url url: String,
|
||||
): NetworkResult<FillAssistFormsJson>
|
||||
}
|
||||
@@ -82,6 +82,9 @@ data class ConfigResponseJson(
|
||||
|
||||
@SerialName("sso")
|
||||
val ssoUrl: String?,
|
||||
|
||||
@SerialName("fillAssistRules")
|
||||
val fillAssistRulesUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Represents the fill-assist forms rules file.
|
||||
*
|
||||
* @property schemaVersion The semantic version string for this file (e.g. "1.0.0").
|
||||
* @property hosts Map of hostname (optionally with port) to [HostEntryJson], or null if the host
|
||||
* is explicitly excluded from fill-assist.
|
||||
*/
|
||||
@Serializable
|
||||
data class FillAssistFormsJson(
|
||||
@SerialName("schemaVersion")
|
||||
val schemaVersion: String? = null,
|
||||
|
||||
@SerialName("hosts")
|
||||
val hosts: Map<String, HostEntryJson?>? = null,
|
||||
) {
|
||||
/**
|
||||
* Form descriptions and pathname-specific overrides for a single host.
|
||||
*
|
||||
* @property forms Site-wide fallback form descriptions.
|
||||
* @property pathnames Pathname-specific overrides; a null value means that path is excluded.
|
||||
*/
|
||||
@Serializable
|
||||
data class HostEntryJson(
|
||||
@SerialName("forms")
|
||||
val forms: List<FormJson>? = null,
|
||||
|
||||
@SerialName("pathnames")
|
||||
val pathnames: Map<String, PathnameEntryJson?>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Form descriptions for a specific pathname.
|
||||
*
|
||||
* @property forms The form descriptions for this path.
|
||||
*/
|
||||
@Serializable
|
||||
data class PathnameEntryJson(
|
||||
@SerialName("forms")
|
||||
val forms: List<FormJson>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Describes one logical form on a page.
|
||||
*
|
||||
* @property category The categorical purpose of this form (e.g. "account-login").
|
||||
* @property container Optional CSS selectors identifying the form's container element.
|
||||
* @property fields Map of field key to [JsonElement] representing a compositeSelectorArray.
|
||||
* Each array element is either a CSS selector string or an array of strings for composite
|
||||
* multi-input fields. Unknown fields are gracefully ignored via [ignoreUnknownKeys].
|
||||
*/
|
||||
@Serializable
|
||||
data class FormJson(
|
||||
@SerialName("category")
|
||||
val category: String? = null,
|
||||
|
||||
@SerialName("container")
|
||||
val container: List<String>? = null,
|
||||
|
||||
@SerialName("fields")
|
||||
val fields: Map<String, JsonElement>? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the fill-assist manifest returned by the fill-assist service.
|
||||
*
|
||||
* @property buildId The unique identifier for this build.
|
||||
* @property timestamp The ISO-8601 timestamp when this build was produced.
|
||||
* @property gitSha The git commit SHA for this build.
|
||||
* @property maps The map data entries keyed by map type.
|
||||
*/
|
||||
@Serializable
|
||||
data class FillAssistManifestJson(
|
||||
@SerialName("buildId")
|
||||
val buildId: String? = null,
|
||||
|
||||
@SerialName("timestamp")
|
||||
val timestamp: String? = null,
|
||||
|
||||
@SerialName("gitSha")
|
||||
val gitSha: String? = null,
|
||||
|
||||
@SerialName("maps")
|
||||
val maps: MapsJson? = null,
|
||||
) {
|
||||
/**
|
||||
* Container for all available maps.
|
||||
*
|
||||
* @property forms Map of schema version string (e.g. "v1", "v2") to [FileEntryJson].
|
||||
* Using a [Map] allows new versions to appear automatically without model changes.
|
||||
*/
|
||||
@Serializable
|
||||
data class MapsJson(
|
||||
@SerialName("forms")
|
||||
val forms: Map<String, FileEntryJson?>?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Metadata for a single versioned file in a map.
|
||||
*
|
||||
* @property filename The filename to fetch (e.g. "forms.v0.json").
|
||||
* @property cid The SHA-256 content hash in "sha256:<hex>" format. Used as a staleness key
|
||||
* to detect when the forms file has changed on the server, avoiding unnecessary re-downloads.
|
||||
* @property schema The schema filename associated with this file version.
|
||||
* @property deprecated When true, this version has entered its end-of-life support window.
|
||||
* Consumers should plan migration but may continue using the version until it is removed.
|
||||
*/
|
||||
@Serializable
|
||||
data class FileEntryJson(
|
||||
@SerialName("filename")
|
||||
val filename: String? = null,
|
||||
|
||||
@SerialName("cid")
|
||||
val cid: String? = null,
|
||||
|
||||
@SerialName("schema")
|
||||
val schema: String? = null,
|
||||
|
||||
@SerialName("deprecated")
|
||||
val deprecated: Boolean? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.model.FillAssistManifestJson
|
||||
|
||||
/**
|
||||
* Provides access to the fill-assist targeting rules service.
|
||||
*/
|
||||
interface FillAssistService {
|
||||
/**
|
||||
* Fetches and parses the fill-assist manifest from [url].
|
||||
*/
|
||||
suspend fun getManifest(url: String): Result<FillAssistManifestJson>
|
||||
|
||||
/**
|
||||
* Downloads and parses the forms rules file from [formsUrl].
|
||||
*
|
||||
* Returns [Result.failure] if the network request fails or parsing fails.
|
||||
* Version-agnostic: any forms file URL can be passed regardless of schema version.
|
||||
*/
|
||||
suspend fun getForms(formsUrl: String): Result<FillAssistFormsJson>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.api.FillAssistApi
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.model.FillAssistManifestJson
|
||||
import com.bitwarden.network.util.toResult
|
||||
|
||||
/**
|
||||
* Default implementation of [FillAssistService].
|
||||
*/
|
||||
internal class FillAssistServiceImpl(
|
||||
private val api: FillAssistApi,
|
||||
) : FillAssistService {
|
||||
|
||||
override suspend fun getManifest(url: String): Result<FillAssistManifestJson> =
|
||||
api.getManifest(url = url).toResult()
|
||||
|
||||
override suspend fun getForms(formsUrl: String): Result<FillAssistFormsJson> =
|
||||
api.getForms(url = formsUrl).toResult()
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
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.Test
|
||||
|
||||
class FillAssistFormsJsonTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `deserialize simple login form`() {
|
||||
assertEquals(
|
||||
SIMPLE_LOGIN_FORMS,
|
||||
json.decodeFromString<FillAssistFormsJson>(SIMPLE_LOGIN_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize host with null value is excluded host`() {
|
||||
val result = json.decodeFromString<FillAssistFormsJson>(NULL_HOST_JSON)
|
||||
assertNull(result.hosts?.get("excluded.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize null pathname is excluded path`() {
|
||||
val result = json.decodeFromString<FillAssistFormsJson>(NULL_PATHNAME_JSON)
|
||||
assertNull(result.hosts?.get("example.com")?.pathnames?.get("/excluded"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize composite OTP field`() {
|
||||
assertEquals(
|
||||
OTP_FORMS,
|
||||
json.decodeFromString<FillAssistFormsJson>(OTP_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize compound selector with multiple alternatives`() {
|
||||
assertEquals(
|
||||
HONEYPOT_FORMS,
|
||||
json.decodeFromString<FillAssistFormsJson>(HONEYPOT_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize form with container`() {
|
||||
assertEquals(
|
||||
CONTAINER_FORMS,
|
||||
json.decodeFromString<FillAssistFormsJson>(CONTAINER_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize form with actions field is graceful`() {
|
||||
// actions is intentionally excluded from FormJson — handled by ignoreUnknownKeys.
|
||||
assertEquals(
|
||||
SIMPLE_LOGIN_FORMS,
|
||||
json.decodeFromString<FillAssistFormsJson>(SIMPLE_LOGIN_WITH_ACTIONS_JSON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val SIMPLE_LOGIN_FORMS = FillAssistFormsJson(
|
||||
schemaVersion = "1.0.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#username"))),
|
||||
"password" to JsonArray(listOf(JsonPrimitive("input#password"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
pathnames = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val OTP_FORMS = FillAssistFormsJson(
|
||||
schemaVersion = "1.0.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(
|
||||
"oneTimeCode" to JsonArray(
|
||||
listOf(
|
||||
JsonArray(
|
||||
listOf(
|
||||
JsonPrimitive("input[name='otp-0']"),
|
||||
JsonPrimitive("input[name='otp-1']"),
|
||||
JsonPrimitive("input[name='otp-2']"),
|
||||
JsonPrimitive("input[name='otp-3']"),
|
||||
JsonPrimitive("input[name='otp-4']"),
|
||||
JsonPrimitive("input[name='otp-5']"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val HONEYPOT_FORMS = FillAssistFormsJson(
|
||||
schemaVersion = "1.0.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#password[name='password']"),
|
||||
JsonPrimitive("input[name='password']"),
|
||||
),
|
||||
),
|
||||
"password" to JsonArray(
|
||||
listOf(
|
||||
JsonPrimitive("input#username[name='username']"),
|
||||
JsonPrimitive("input[name='username']"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
pathnames = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val CONTAINER_FORMS = FillAssistFormsJson(
|
||||
schemaVersion = "1.0.0",
|
||||
hosts = mapOf(
|
||||
"example.com" to FillAssistFormsJson.HostEntryJson(
|
||||
forms = null,
|
||||
pathnames = mapOf(
|
||||
"/login" to FillAssistFormsJson.PathnameEntryJson(
|
||||
forms = listOf(
|
||||
FillAssistFormsJson.FormJson(
|
||||
category = "account-login",
|
||||
container = listOf("div#login-container"),
|
||||
fields = mapOf(
|
||||
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
|
||||
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val SIMPLE_LOGIN_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"fields": {
|
||||
"username": ["input#username"],
|
||||
"password": ["input#password"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val NULL_HOST_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"excluded.com": null
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val NULL_PATHNAME_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"pathnames": {
|
||||
"/excluded": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val OTP_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"pathnames": {
|
||||
"/login": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"fields": {
|
||||
"oneTimeCode": [
|
||||
["input[name='otp-0']","input[name='otp-1']","input[name='otp-2']",
|
||||
"input[name='otp-3']","input[name='otp-4']","input[name='otp-5']"]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val HONEYPOT_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"fields": {
|
||||
"username": ["input#password[name='password']", "input[name='password']"],
|
||||
"password": ["input#username[name='username']", "input[name='username']"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val CONTAINER_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"pathnames": {
|
||||
"/login": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"container": ["div#login-container"],
|
||||
"fields": {
|
||||
"username": ["input#user"],
|
||||
"password": ["input#pass"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
// Same structure as SIMPLE_LOGIN_JSON with the actions field present — verifies that
|
||||
// actions (intentionally omitted from FormJson) is handled by ignoreUnknownKeys = true.
|
||||
private const val SIMPLE_LOGIN_WITH_ACTIONS_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"fields": {
|
||||
"username": ["input#username"],
|
||||
"password": ["input#password"]
|
||||
},
|
||||
"actions": {
|
||||
"submit": ["button#submit"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
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 FillAssistManifestJsonTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `deserialize full manifest with v0 entry`() {
|
||||
assertEquals(
|
||||
FULL_MANIFEST,
|
||||
json.decodeFromString<FillAssistManifestJson>(MANIFEST_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize manifest with multiple version entries`() {
|
||||
assertEquals(
|
||||
MULTI_VERSION_MANIFEST,
|
||||
json.decodeFromString<FillAssistManifestJson>(MANIFEST_MULTI_VERSION_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize manifest with unknown top-level fields is graceful`() {
|
||||
assertEquals(
|
||||
FULL_MANIFEST,
|
||||
json.decodeFromString<FillAssistManifestJson>(MANIFEST_EXTRA_FIELDS_JSON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize minimal manifest with null fields`() {
|
||||
val result = json.decodeFromString<FillAssistManifestJson>("{}")
|
||||
assertNull(result.buildId)
|
||||
assertNull(result.maps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize manifest with deprecated version entry`() {
|
||||
assertEquals(
|
||||
DEPRECATED_MANIFEST,
|
||||
json.decodeFromString<FillAssistManifestJson>(MANIFEST_DEPRECATED_JSON),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val FULL_MANIFEST = FillAssistManifestJson(
|
||||
buildId = "local-build",
|
||||
timestamp = "2026-05-20T15:01:02.956Z",
|
||||
gitSha = "abc123",
|
||||
maps = FillAssistManifestJson.MapsJson(
|
||||
forms = mapOf(
|
||||
"v0" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v0.json",
|
||||
cid = "sha256:abc123def456",
|
||||
schema = "forms.v0.schema.json",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val MULTI_VERSION_MANIFEST = FillAssistManifestJson(
|
||||
buildId = "local-build",
|
||||
timestamp = null,
|
||||
gitSha = null,
|
||||
maps = FillAssistManifestJson.MapsJson(
|
||||
forms = mapOf(
|
||||
"v0" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v0.json",
|
||||
cid = "sha256:aaa",
|
||||
schema = "forms.v0.schema.json",
|
||||
),
|
||||
"v1" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v1.json",
|
||||
cid = "sha256:bbb",
|
||||
schema = "forms.v1.schema.json",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val MANIFEST_JSON = """
|
||||
{
|
||||
"buildId": "local-build",
|
||||
"timestamp": "2026-05-20T15:01:02.956Z",
|
||||
"gitSha": "abc123",
|
||||
"maps": {
|
||||
"forms": {
|
||||
"v0": {
|
||||
"filename": "forms.v0.json",
|
||||
"cid": "sha256:abc123def456",
|
||||
"schema": "forms.v0.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val MANIFEST_MULTI_VERSION_JSON = """
|
||||
{
|
||||
"buildId": "local-build",
|
||||
"maps": {
|
||||
"forms": {
|
||||
"v0": { "filename": "forms.v0.json", "cid": "sha256:aaa", "schema": "forms.v0.schema.json" },
|
||||
"v1": { "filename": "forms.v1.json", "cid": "sha256:bbb", "schema": "forms.v1.schema.json" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private val DEPRECATED_MANIFEST = FillAssistManifestJson(
|
||||
buildId = "local-build",
|
||||
timestamp = null,
|
||||
gitSha = null,
|
||||
maps = FillAssistManifestJson.MapsJson(
|
||||
forms = mapOf(
|
||||
"v0" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v0.json",
|
||||
cid = "sha256:abc123def456",
|
||||
schema = "forms.v0.schema.json",
|
||||
deprecated = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val MANIFEST_DEPRECATED_JSON = """
|
||||
{
|
||||
"buildId": "local-build",
|
||||
"maps": {
|
||||
"forms": {
|
||||
"v0": {
|
||||
"filename": "forms.v0.json",
|
||||
"cid": "sha256:abc123def456",
|
||||
"schema": "forms.v0.schema.json",
|
||||
"deprecated": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
// Same structure as MANIFEST_JSON with an extra unknown key — verifies ignoreUnknownKeys = true.
|
||||
private const val MANIFEST_EXTRA_FIELDS_JSON = """
|
||||
{
|
||||
"buildId": "local-build",
|
||||
"timestamp": "2026-05-20T15:01:02.956Z",
|
||||
"gitSha": "abc123",
|
||||
"checksums": "ignored",
|
||||
"maps": {
|
||||
"forms": {
|
||||
"v0": {
|
||||
"filename": "forms.v0.json",
|
||||
"cid": "sha256:abc123def456",
|
||||
"schema": "forms.v0.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
|
||||
notificationsUrl = "notificationsUrl",
|
||||
identityUrl = "identityUrl",
|
||||
ssoUrl = "ssoUrl",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"feature one" to JsonPrimitive(false),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.api.FillAssistApi
|
||||
import com.bitwarden.network.base.BaseServiceTest
|
||||
import com.bitwarden.network.model.FillAssistFormsJson
|
||||
import com.bitwarden.network.model.FillAssistManifestJson
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.create
|
||||
|
||||
class FillAssistServiceTest : BaseServiceTest() {
|
||||
|
||||
private val api: FillAssistApi = retrofit.create()
|
||||
private val service = FillAssistServiceImpl(api = api)
|
||||
|
||||
@Test
|
||||
fun `getManifest should parse manifest response`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(MANIFEST_JSON))
|
||||
assertEquals(MANIFEST.asSuccess(), service.getManifest(url = "$urlPrefix/manifest.json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getManifest should return failure on server error`() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
assertTrue(service.getManifest(url = "$urlPrefix/manifest.json").isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getForms should parse and return forms`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(FORMS_V1_JSON))
|
||||
assertEquals(FORMS_V1.asSuccess(), service.getForms(formsUrl = "$urlPrefix/forms.v1.json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getForms should return failure on server error`() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
assertTrue(service.getForms(formsUrl = "$urlPrefix/forms.v1.json").isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
private val MANIFEST = FillAssistManifestJson(
|
||||
buildId = "local-build",
|
||||
timestamp = "2026-05-20T15:01:02.956Z",
|
||||
gitSha = "abc123",
|
||||
maps = FillAssistManifestJson.MapsJson(
|
||||
forms = mapOf(
|
||||
"v1" to FillAssistManifestJson.FileEntryJson(
|
||||
filename = "forms.v1.json",
|
||||
cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
|
||||
schema = "forms.v1.schema.json",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val FORMS_V1 = FillAssistFormsJson(
|
||||
schemaVersion = "1.0.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"))),
|
||||
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
pathnames = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val MANIFEST_JSON = """
|
||||
{
|
||||
"buildId": "local-build",
|
||||
"timestamp": "2026-05-20T15:01:02.956Z",
|
||||
"gitSha": "abc123",
|
||||
"maps": {
|
||||
"forms": {
|
||||
"v1": {
|
||||
"filename": "forms.v1.json",
|
||||
"cid": "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
|
||||
"schema": "forms.v1.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private const val FORMS_V1_JSON = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"hosts": {
|
||||
"example.com": {
|
||||
"forms": [
|
||||
{
|
||||
"category": "account-login",
|
||||
"fields": {
|
||||
"username": ["input#user"],
|
||||
"password": ["input#pass"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
Reference in New Issue
Block a user