diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensions.kt index f36b83a21..1a30422d7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensions.kt @@ -4,12 +4,33 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.util.policyInformation import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** * Get a list of active policies with the data decoded to the specified type. */ -inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): List<T> { - val type = when (T::class.java) { +inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): List<T> = + this + .getActivePolicies(type = getPolicyTypeJson<T>()) + .mapNotNull { it.policyInformation as? T } + +/** + * Gets a flow of all the active policies with the data decoded to the specified type. + */ +inline fun <reified T : PolicyInformation> PolicyManager.getActivePoliciesFlow(): Flow<List<T>> = + this + .getActivePoliciesFlow(type = getPolicyTypeJson<T>()) + .map { policies -> + policies.mapNotNull { policy -> policy.policyInformation as? T } + } + +/** + * Helper method for mapping a specific [PolicyInformation] type to its [PolicyTypeJson] + * counterpart. + */ +inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson = + when (T::class.java) { PolicyInformation.MasterPassword::class.java -> PolicyTypeJson.MASTER_PASSWORD PolicyInformation.PasswordGenerator::class.java -> PolicyTypeJson.PASSWORD_GENERATOR PolicyInformation.SendOptions::class.java -> PolicyTypeJson.SEND_OPTIONS @@ -18,11 +39,7 @@ inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): Li else -> { throw IllegalStateException( "Looks like you are missing a branch in your when statement. Update " + - "getActivePolicies() to handle all PolicyInformation implementations.", + "getPolicyTypeJson() to handle all PolicyInformation implementations.", ) } } - return this - .getActivePolicies(type = type) - .mapNotNull { it.policyInformation as? T } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index b0b6f13ed..ae3eea09d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies +import com.x8bit.bitwarden.data.platform.manager.util.getActivePoliciesFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwardedServiceUsernameResult @@ -49,6 +50,7 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.util.toUsernameGeneratorRe import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -103,6 +105,11 @@ class GeneratorViewModel @Inject constructor( is Passcode -> loadPasscodeOptions(selectedType, usePolicyDefault = true) is Username -> loadUsernameOptions(selectedType) } + policyManager + .getActivePoliciesFlow<PolicyInformation.PasswordGenerator>() + .map { GeneratorAction.Internal.PasswordGeneratorPolicyReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: GeneratorAction) { @@ -204,6 +211,10 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult -> { handleUpdateForwardedServiceGeneratedUsernameResult(action) } + + is GeneratorAction.Internal.PasswordGeneratorPolicyReceive -> { + handlePasswordGeneratorPolicyReceive(action) + } } } @@ -637,6 +648,12 @@ class GeneratorViewModel @Inject constructor( } } + private fun handlePasswordGeneratorPolicyReceive( + action: GeneratorAction.Internal.PasswordGeneratorPolicyReceive, + ) { + mutableStateFlow.update { it.copy(isUnderPolicy = action.policies.any()) } + } + //endregion Generated Field Handlers //region Main Type Option Handlers @@ -2251,6 +2268,13 @@ sealed class GeneratorAction { * Models actions that the [GeneratorViewModel] itself might send. */ sealed class Internal : GeneratorAction() { + /** + * Indicates that updated policies have been received. + */ + data class PasswordGeneratorPolicyReceive( + val policies: List<PolicyInformation.PasswordGenerator>, + ) : Internal() + /** * Indicates a generated text update is received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensionsTest.kt new file mode 100644 index 000000000..19de73663 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/PolicyManagerExtensionsTest.kt @@ -0,0 +1,132 @@ +package com.x8bit.bitwarden.data.platform.manager.util + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.platform.manager.PolicyManager +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PolicyManagerExtensionsTest { + private val mutablePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>() + private val policyManager: PolicyManager = mockk { + every { getActivePoliciesFlow(any()) } returns mutablePolicyFlow + } + + @Test + fun `getActivePolicies should filter out undesired policies`() { + every { + policyManager.getActivePolicies(any()) + } returns listOf(MASTER_PASSWORD_POLICY, PASSWORD_GENERATOR_POLICY) + + val result = policyManager.getActivePolicies<PolicyInformation.MasterPassword>() + + assertEquals(listOf(MASTER_PASSWORD_POLICY_INFO), result) + } + + @Test + fun `getActivePoliciesFlow should filter out undesired policies when policyManager emits`() = + runTest { + policyManager.getActivePoliciesFlow<PolicyInformation.MasterPassword>().test { + mutablePolicyFlow.tryEmit(listOf(PASSWORD_GENERATOR_POLICY)) + assertEquals(emptyList<PolicyInformation.MasterPassword>(), awaitItem()) + mutablePolicyFlow.tryEmit( + listOf( + MASTER_PASSWORD_POLICY, + PASSWORD_GENERATOR_POLICY, + ), + ) + assertEquals(listOf(MASTER_PASSWORD_POLICY_INFO), awaitItem()) + } + } + + @Test + fun `getPolicyTypeJson with MasterPassword should map to appropriate PolicyTypeJson`() { + assertEquals( + PolicyTypeJson.MASTER_PASSWORD, + getPolicyTypeJson<PolicyInformation.MasterPassword>(), + ) + } + + @Test + fun `getPolicyTypeJson with PasswordGenerator should map to appropriate PolicyTypeJson`() { + assertEquals( + PolicyTypeJson.PASSWORD_GENERATOR, + getPolicyTypeJson<PolicyInformation.PasswordGenerator>(), + ) + } + + @Test + fun `getPolicyTypeJson with SendOptions should map to appropriate PolicyTypeJson`() { + assertEquals( + PolicyTypeJson.SEND_OPTIONS, + getPolicyTypeJson<PolicyInformation.SendOptions>(), + ) + } + + @Test + fun `getPolicyTypeJson with VaultTimeout should map to appropriate PolicyTypeJson`() { + assertEquals( + PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT, + getPolicyTypeJson<PolicyInformation.VaultTimeout>(), + ) + } +} + +private val MASTER_PASSWORD_POLICY = SyncResponseJson.Policy( + organizationId = "organizationId", + id = "master_password_id", + type = PolicyTypeJson.MASTER_PASSWORD, + isEnabled = true, + data = JsonObject( + mapOf( + "minLength" to JsonPrimitive(10), + "minComplexity" to JsonPrimitive(10), + "requireUpper" to JsonPrimitive(false), + "requireLower" to JsonPrimitive(true), + "requireNumbers" to JsonNull, + "requireSpecial" to JsonPrimitive(false), + "enforceOnLogin" to JsonPrimitive(true), + ), + ), +) + +private val MASTER_PASSWORD_POLICY_INFO = PolicyInformation.MasterPassword( + minLength = 10, + minComplexity = 10, + requireUpper = false, + requireLower = true, + requireNumbers = null, + requireSpecial = false, + enforceOnLogin = true, +) + +private val PASSWORD_GENERATOR_POLICY = SyncResponseJson.Policy( + organizationId = "organizationId", + id = "password_generator_id", + type = PolicyTypeJson.PASSWORD_GENERATOR, + isEnabled = true, + data = JsonObject( + mapOf( + "defaultType" to JsonNull, + "minLength" to JsonPrimitive(10), + "useUpper" to JsonPrimitive(true), + "useNumbers" to JsonPrimitive(true), + "useSpecial" to JsonPrimitive(true), + "minNumbers" to JsonPrimitive(3), + "minSpecial" to JsonPrimitive(3), + "minNumberWords" to JsonPrimitive(5), + "capitalize" to JsonPrimitive(true), + "includeNumber" to JsonPrimitive(true), + "useLower" to JsonPrimitive(true), + ), + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index cff22a0c0..43a2845d0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedForwardedServiceUsernameResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult @@ -19,6 +20,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -91,8 +93,10 @@ class GeneratorViewModelTest : BaseViewModelTest() { ) } + private val mutablePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>() private val policyManager: PolicyManager = mockk { every { getActivePolicies(PolicyTypeJson.PASSWORD_GENERATOR) } returns emptyList() + every { getActivePoliciesFlow(PolicyTypeJson.PASSWORD_GENERATOR) } returns mutablePolicyFlow } @Test @@ -107,6 +111,41 @@ class GeneratorViewModelTest : BaseViewModelTest() { assertEquals(initialPasscodeState, viewModel.stateFlow.value) } + @Test + fun `activePolicyFlow changes should update state`() = runTest { + val payload = mapOf( + "defaultType" to JsonNull, + "minLength" to JsonPrimitive(10), + "useUpper" to JsonPrimitive(true), + "useNumbers" to JsonPrimitive(true), + "useSpecial" to JsonPrimitive(true), + "minNumbers" to JsonPrimitive(3), + "minSpecial" to JsonPrimitive(3), + "minNumberWords" to JsonPrimitive(5), + "capitalize" to JsonPrimitive(true), + "includeNumber" to JsonPrimitive(true), + "useLower" to JsonPrimitive(true), + ) + val policies = listOf( + SyncResponseJson.Policy( + organizationId = "organizationId", + id = "id", + type = PolicyTypeJson.PASSWORD_GENERATOR, + isEnabled = true, + data = JsonObject(payload), + ), + ) + val viewModel = createViewModel(state = initialPasscodeState) + + viewModel.stateFlow.test { + assertEquals(initialPasscodeState, awaitItem()) + mutablePolicyFlow.tryEmit(value = policies) + assertEquals(initialPasscodeState.copy(isUnderPolicy = true), awaitItem()) + mutablePolicyFlow.tryEmit(value = emptyList()) + assertEquals(initialPasscodeState.copy(isUnderPolicy = false), awaitItem()) + } + } + @Test fun `CloseClick should emit NavigateBack event`() = runTest { val viewModel = createViewModel()