diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index ed54531db2c..406fb7de784 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index d0b7dd2f488..908fdbe3e70 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -8106,6 +8106,7 @@ class AuthRepositoryTest { identityUrl = "mockIdentityUrl", notificationsUrl = "mockNotificationsUrl", ssoUrl = "mockSsoUrl", + fillAssistRulesUrl = null, ), featureStates = emptyMap(), communication = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt index 8b2c98eff0f..82368ce2fc2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt @@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest { identityUrl = null, notificationsUrl = null, ssoUrl = null, + fillAssistRulesUrl = null, ), featureStates = null, communication = ConfigResponseJson.CommunicationJson( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt index 2228c33b6a8..aeceb2ef29f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt @@ -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), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt index 07055353b56..5ce969386af 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt @@ -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), diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt index b8e29a72510..ef0545cee72 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt @@ -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), diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt index aaf0ffa2c1b..e7e5ad94f60 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt @@ -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), diff --git a/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt index 6093171fbe7..062d57b5624 100644 --- a/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt +++ b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt @@ -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), diff --git a/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt b/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt index 4e32741fb5e..abc7c4963fe 100644 --- a/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt +++ b/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt @@ -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), diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt index 1824ea01a28..e1b0203a055 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt @@ -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. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index f054cb3e010..7afab54ab64 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -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 diff --git a/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt new file mode 100644 index 00000000000..0ca1c93d239 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt @@ -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 + + /** + * Fetches and decodes the forms rules file from [url]. + */ + @GET + suspend fun getForms( + @Url url: String, + ): NetworkResult +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt index d0cb32cf9a2..834ab78bf32 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt @@ -82,6 +82,9 @@ data class ConfigResponseJson( @SerialName("sso") val ssoUrl: String?, + + @SerialName("fillAssistRules") + val fillAssistRulesUrl: String?, ) /** diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt new file mode 100644 index 00000000000..9f56cca659a --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt @@ -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? = 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? = null, + + @SerialName("pathnames") + val pathnames: Map? = null, + ) + + /** + * Form descriptions for a specific pathname. + * + * @property forms The form descriptions for this path. + */ + @Serializable + data class PathnameEntryJson( + @SerialName("forms") + val forms: List? = 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? = null, + + @SerialName("fields") + val fields: Map? = null, + ) +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt new file mode 100644 index 00000000000..460039ff032 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt @@ -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?, + ) + + /** + * 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:" 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, + ) +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt new file mode 100644 index 00000000000..007a25f13df --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt @@ -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 + + /** + * 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 +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt new file mode 100644 index 00000000000..7668e4b346b --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt @@ -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 = + api.getManifest(url = url).toResult() + + override suspend fun getForms(formsUrl: String): Result = + api.getForms(url = formsUrl).toResult() +} diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt new file mode 100644 index 00000000000..532154fbb98 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt @@ -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(SIMPLE_LOGIN_JSON), + ) + } + + @Test + fun `deserialize host with null value is excluded host`() { + val result = json.decodeFromString(NULL_HOST_JSON) + assertNull(result.hosts?.get("excluded.com")) + } + + @Test + fun `deserialize null pathname is excluded path`() { + val result = json.decodeFromString(NULL_PATHNAME_JSON) + assertNull(result.hosts?.get("example.com")?.pathnames?.get("/excluded")) + } + + @Test + fun `deserialize composite OTP field`() { + assertEquals( + OTP_FORMS, + json.decodeFromString(OTP_JSON), + ) + } + + @Test + fun `deserialize compound selector with multiple alternatives`() { + assertEquals( + HONEYPOT_FORMS, + json.decodeFromString(HONEYPOT_JSON), + ) + } + + @Test + fun `deserialize form with container`() { + assertEquals( + CONTAINER_FORMS, + json.decodeFromString(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(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"] + } + } + ] + } + } +} +""" diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt new file mode 100644 index 00000000000..4aac50c46ad --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt @@ -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(MANIFEST_JSON), + ) + } + + @Test + fun `deserialize manifest with multiple version entries`() { + assertEquals( + MULTI_VERSION_MANIFEST, + json.decodeFromString(MANIFEST_MULTI_VERSION_JSON), + ) + } + + @Test + fun `deserialize manifest with unknown top-level fields is graceful`() { + assertEquals( + FULL_MANIFEST, + json.decodeFromString(MANIFEST_EXTRA_FIELDS_JSON), + ) + } + + @Test + fun `deserialize minimal manifest with null fields`() { + val result = json.decodeFromString("{}") + assertNull(result.buildId) + assertNull(result.maps) + } + + @Test + fun `deserialize manifest with deprecated version entry`() { + assertEquals( + DEPRECATED_MANIFEST, + json.decodeFromString(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" + } + } + } +} +""" diff --git a/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt index f6d3b757ab9..0a0380c3776 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt @@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson( notificationsUrl = "notificationsUrl", identityUrl = "identityUrl", ssoUrl = "ssoUrl", + fillAssistRulesUrl = null, ), featureStates = mapOf( "feature one" to JsonPrimitive(false), diff --git a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt new file mode 100644 index 00000000000..80e625716d6 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt @@ -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"] + } + } + ] + } + } +} +"""