mirror of
https://github.com/bitwarden/android.git
synced 2026-06-10 00:28:29 -05:00
Add fill assist rules network data
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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