Add fill assist rules network data

This commit is contained in:
Andre Rosado
2026-05-29 12:03:19 +01:00
parent e7e2c26bef
commit c6463722f2
21 changed files with 854 additions and 34 deletions

View File

@@ -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(

View File

@@ -46,6 +46,7 @@ import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.PreLoginResponseJson
import com.bitwarden.network.model.PrevalidateSsoResponseJson
import com.bitwarden.network.model.RefreshTokenResponseJson
@@ -56,6 +57,7 @@ import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SendVerificationEmailRequestJson
import com.bitwarden.network.model.SendVerificationEmailResponseJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
@@ -67,13 +69,12 @@ import com.bitwarden.network.model.VerifyEmailTokenResponseJson
import com.bitwarden.network.model.createMockAccountKeysJson
import com.bitwarden.network.model.createMockAccountKeysJsonWithNullFields
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -147,7 +148,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@@ -168,6 +168,8 @@ import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -264,14 +266,14 @@ class AuthRepositoryTest {
private val mutableLogoutFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow<String>()
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<PolicyView>>()
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val pushManager: PushManager = mockk {
every { logoutFlow } returns mutableLogoutFlow
every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow
}
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
} returns mutableActivePolicyFlow
}
private val logsManager: LogsManager = mockk {
@@ -468,20 +470,18 @@ class AuthRepositoryTest {
// Set policies that will fail the password.
mutableActivePolicyFlow.emit(
listOf(
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":100,
"minComplexity":null,
"requireUpper":null,
"requireLower":null,
"requireNumbers":null,
"requireSpecial":null,
"enforceOnLogin":true
}
""",
createMockPolicy(
type = PolicyTypeJson.MASTER_PASSWORD,
isEnabled = true,
data = buildJsonObject {
put(key = "minLength", value = 100)
put(key = "minComplexity", value = null)
put(key = "requireUpper", value = null)
put(key = "requireLower", value = null)
put(key = "requireNumbers", value = null)
put(key = "requireSpecial", value = null)
put(key = "enforceOnLogin", value = true)
},
),
),
)
@@ -7344,22 +7344,20 @@ class AuthRepositoryTest {
requireSpecial: Boolean = false,
) {
every {
policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD)
policyManager.getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD)
} returns listOf(
createMockPolicyView(
type = PolicyType.MASTER_PASSWORD,
enabled = true,
data = """
{
"minLength":$minLength,
"minComplexity":$minComplexity,
"requireUpper":$requireUpper,
"requireLower":$requireLower,
"requireNumbers":$requireNumbers,
"requireSpecial":$requireSpecial,
"enforceOnLogin":true
}
""",
createMockPolicy(
type = PolicyTypeJson.MASTER_PASSWORD,
isEnabled = true,
data = buildJsonObject {
put(key = "minLength", value = minLength)
put(key = "minComplexity", value = minComplexity)
put(key = "requireUpper", value = requireUpper)
put(key = "requireLower", value = requireLower)
put(key = "requireNumbers", value = requireNumbers)
put(key = "requireSpecial", value = requireSpecial)
put(key = "enforceOnLogin", value = true)
},
),
)
}
@@ -8106,6 +8104,7 @@ class AuthRepositoryTest {
identityUrl = "mockIdentityUrl",
notificationsUrl = "mockNotificationsUrl",
ssoUrl = "mockSsoUrl",
fillAssistRulesUrl = null,
),
featureStates = emptyMap(),
communication = null,

View File

@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
identityUrl = null,
notificationsUrl = null,
ssoUrl = null,
fillAssistRulesUrl = null,
),
featureStates = null,
communication = ConfigResponseJson.CommunicationJson(

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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>
}

View File

@@ -82,6 +82,9 @@ data class ConfigResponseJson(
@SerialName("sso")
val ssoUrl: String?,
@SerialName("fillAssistRules")
val fillAssistRulesUrl: String?,
)
/**

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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>
}

View File

@@ -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()
}

View File

@@ -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"]
}
}
]
}
}
}
"""

View File

@@ -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"
}
}
}
}
"""

View File

@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
notificationsUrl = "notificationsUrl",
identityUrl = "identityUrl",
ssoUrl = "ssoUrl",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"feature one" to JsonPrimitive(false),

View File

@@ -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"]
}
}
]
}
}
}
"""