BIT-785: Vault timeout policy (#924)

This commit is contained in:
Shannon Draeker 2024-01-31 23:05:09 -07:00 committed by Álison Fernandes
parent c7f063a306
commit 89dd552908
12 changed files with 498 additions and 37 deletions

View file

@ -98,4 +98,16 @@ sealed class PolicyInformation {
const val TYPE_PASSPHRASE: String = "passphrase"
}
}
/**
* Represents a policy enforcing rules on the user's vault timeout settings.
*/
@Serializable
data class VaultTimeout(
@SerialName("minutes")
val minutes: Int?,
@SerialName("action")
val action: String?,
) : PolicyInformation()
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.serialization.json.Json
@ -29,10 +30,15 @@ val SyncResponseJson.Policy.policyInformation: PolicyInformation?
get() = data?.toString()?.let {
when (type) {
PolicyTypeJson.MASTER_PASSWORD -> {
Json.decodeFromString<PolicyInformation.MasterPassword>(it)
Json.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
Json.decodeFromString<PolicyInformation.PasswordGenerator>(it)
Json.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
Json.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
else -> null

View file

@ -3,16 +3,21 @@ package com.x8bit.bitwarden.data.platform.repository
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
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.sdk.VaultSdkSource
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
@ -24,7 +29,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
@ -42,6 +49,7 @@ class SettingsRepositoryImpl(
private val settingsDiskSource: SettingsDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val policyManager: PolicyManager,
private val dispatcherManager: DispatcherManager,
) : SettingsRepository {
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@ -286,6 +294,13 @@ class SettingsRepositoryImpl(
?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED,
)
init {
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
.onEach { updateVaultUnlockSettingsIfNecessary(it) }
.launchIn(unconfinedScope)
}
override fun disableAutofill() {
autofillManager.disableAutofillServices()
@ -451,6 +466,36 @@ class SettingsRepositoryImpl(
)
}
}
/**
* Check the parameters of the vault unlock policy against the user's
* settings to determine whether to update the user's settings.
*/
private fun updateVaultUnlockSettingsIfNecessary(
policies: List<SyncResponseJson.Policy>,
) {
// The vault timeout policy can only be implemented in organizations that have
// the single organization policy, meaning that if this is enabled, the user is
// only in one organization and hence there is only one result in the list.
val vaultUnlockPolicy = policies
.firstOrNull()
?.policyInformation as? PolicyInformation.VaultTimeout
?: return
// Adjust the user's timeout or method if necessary to meet the policy requirements.
vaultUnlockPolicy.minutes?.let { maxMinutes ->
if ((vaultTimeout.vaultTimeoutInMinutes ?: Int.MAX_VALUE) > maxMinutes) {
vaultTimeout = VaultTimeout.Custom(maxMinutes)
}
}
vaultUnlockPolicy.action?.let {
vaultTimeoutAction = if (it == "lock") {
VaultTimeoutAction.LOCK
} else {
VaultTimeoutAction.LOGOUT
}
}
}
}
/**

View file

@ -5,8 +5,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
@ -44,12 +44,12 @@ object PlatformRepositoryModule {
fun provideSettingsRepository(
autofillManager: AutofillManager,
autofillEnabledManager: AutofillEnabledManager,
appForegroundManager: AppForegroundManager,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
vaultSdkSource: VaultSdkSource,
encryptionManager: BiometricsEncryptionManager,
dispatcherManager: DispatcherManager,
policyManager: PolicyManager,
): SettingsRepository =
SettingsRepositoryImpl(
autofillManager = autofillManager,
@ -59,5 +59,6 @@ object PlatformRepositoryModule {
vaultSdkSource = vaultSdkSource,
biometricsEncryptionManager = encryptionManager,
dispatcherManager = dispatcherManager,
policyManager = policyManager,
)
}

View file

@ -37,10 +37,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLogoutConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenPolicyWarningText
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
@ -60,6 +64,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.platform.theme.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import com.x8bit.bitwarden.ui.platform.util.minutes
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.LocalTime
@ -210,7 +215,15 @@ fun AccountSecurityScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SessionTimeoutPolicyRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SessionTimeoutRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
selectedVaultTimeoutType = state.vaultTimeout.type,
onVaultTimeoutTypeSelect = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.VaultTimeoutTypeSelect(it)) }
@ -219,6 +232,7 @@ fun AccountSecurityScreen(
)
(state.vaultTimeout as? VaultTimeout.Custom)?.let { customTimeout ->
SessionCustomTimeoutRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
customVaultTimeout = customTimeout,
onCustomVaultTimeoutSelect = remember(viewModel) {
{
@ -231,6 +245,7 @@ fun AccountSecurityScreen(
)
}
SessionTimeoutActionRow(
vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction,
selectedVaultTimeoutAction = state.vaultTimeoutAction,
onVaultTimeoutActionSelect = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.VaultTimeoutActionSelect(it)) }
@ -468,9 +483,44 @@ private fun UnlockWithPinRow(
}
}
@Composable
private fun SessionTimeoutPolicyRow(
vaultTimeoutPolicyMinutes: Int?,
vaultTimeoutPolicyAction: String?,
modifier: Modifier = Modifier,
) {
// Show the policy warning if applicable.
if (vaultTimeoutPolicyMinutes != null || !vaultTimeoutPolicyAction.isNullOrBlank()) {
// Calculate the hours and minutes to show in the policy label.
val hours = vaultTimeoutPolicyMinutes?.floorDiv(MINUTES_PER_HOUR)
val minutes = vaultTimeoutPolicyMinutes?.mod(MINUTES_PER_HOUR)
// Get the localized version of the action.
val action = if (vaultTimeoutPolicyAction == "lock") {
R.string.lock.asText()
} else {
R.string.log_out.asText()
}
val policyText = if (hours == null || minutes == null) {
R.string.vault_timeout_action_policy_in_effect.asText(action)
} else if (vaultTimeoutPolicyAction.isNullOrBlank()) {
R.string.vault_timeout_policy_in_effect.asText(hours, minutes)
} else {
R.string.vault_timeout_policy_with_action_in_effect.asText(hours, minutes, action)
}
BitwardenPolicyWarningText(
text = policyText(),
modifier = modifier,
)
}
}
@Suppress("LongMethod")
@Composable
private fun SessionTimeoutRow(
vaultTimeoutPolicyMinutes: Int?,
selectedVaultTimeoutType: VaultTimeout.Type,
onVaultTimeoutTypeSelect: (VaultTimeout.Type) -> Unit,
modifier: Modifier = Modifier,
@ -492,6 +542,10 @@ private fun SessionTimeoutRow(
when {
shouldShowSelectionDialog -> {
val vaultTimeoutOptions = VaultTimeout.Type.entries
.filter {
it.minutes <= (vaultTimeoutPolicyMinutes ?: Int.MAX_VALUE)
}
BitwardenSelectionDialog(
title = stringResource(id = R.string.session_timeout),
onDismissRequest = { shouldShowSelectionDialog = false },
@ -535,11 +589,13 @@ private fun SessionTimeoutRow(
@Suppress("LongMethod")
@Composable
private fun SessionCustomTimeoutRow(
vaultTimeoutPolicyMinutes: Int?,
customVaultTimeout: VaultTimeout.Custom,
onCustomVaultTimeoutSelect: (VaultTimeout.Custom) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowTimePickerDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowViolatesPoliciesDialog by remember { mutableStateOf(false) }
val vaultTimeoutInMinutes = customVaultTimeout.vaultTimeoutInMinutes
BitwardenTextRow(
text = stringResource(id = R.string.custom),
@ -564,21 +620,49 @@ private fun SessionCustomTimeoutRow(
initialMinute = vaultTimeoutInMinutes.mod(MINUTES_PER_HOUR),
onTimeSelect = { hour, minute ->
shouldShowTimePickerDialog = false
onCustomVaultTimeoutSelect(
VaultTimeout.Custom(
vaultTimeoutInMinutes = hour * MINUTES_PER_HOUR + minute,
),
)
val totalMinutes = (hour * MINUTES_PER_HOUR) + minute
if (vaultTimeoutPolicyMinutes != null &&
totalMinutes > vaultTimeoutPolicyMinutes
) {
shouldShowViolatesPoliciesDialog = true
} else {
onCustomVaultTimeoutSelect(
VaultTimeout.Custom(
vaultTimeoutInMinutes = totalMinutes,
),
)
}
},
onDismissRequest = { shouldShowTimePickerDialog = false },
is24Hour = true,
)
}
if (shouldShowViolatesPoliciesDialog) {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.warning.asText(),
message = R.string.vault_timeout_to_large.asText(),
),
onDismissRequest = {
shouldShowViolatesPoliciesDialog = false
vaultTimeoutPolicyMinutes?.let {
onCustomVaultTimeoutSelect(
VaultTimeout.Custom(
vaultTimeoutInMinutes = it,
),
)
}
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun SessionTimeoutActionRow(
vaultTimeoutPolicyAction: String?,
selectedVaultTimeoutAction: VaultTimeoutAction,
onVaultTimeoutActionSelect: (VaultTimeoutAction) -> Unit,
modifier: Modifier = Modifier,
@ -587,7 +671,11 @@ private fun SessionTimeoutActionRow(
var shouldShowLogoutActionConfirmationDialog by rememberSaveable { mutableStateOf(false) }
BitwardenTextRow(
text = stringResource(id = R.string.session_timeout_action),
onClick = { shouldShowSelectionDialog = true },
onClick = {
// The option is not selectable if there's a policy in place.
if (vaultTimeoutPolicyAction != null) return@BitwardenTextRow
shouldShowSelectionDialog = true
},
modifier = modifier,
) {
Text(

View file

@ -5,19 +5,24 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -36,6 +41,7 @@ class AccountSecurityViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val settingsRepository: SettingsRepository,
private val environmentRepository: EnvironmentRepository,
private val policyManager: PolicyManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
initialState = savedStateHandle[KEY_STATE]
@ -47,6 +53,8 @@ class AccountSecurityViewModel @Inject constructor(
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
vaultTimeout = settingsRepository.vaultTimeout,
vaultTimeoutAction = settingsRepository.vaultTimeoutAction,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
),
) {
private val webSettingsUrl: String
@ -63,6 +71,18 @@ class AccountSecurityViewModel @Inject constructor(
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
.map { policies ->
AccountSecurityAction.Internal.PolicyUpdateReceive(
vaultTimeoutPolicies = policies.mapNotNull {
it.policyInformation as? PolicyInformation.VaultTimeout
},
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch {
trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
@ -268,6 +288,10 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.Internal.FingerprintResultReceive -> {
handleFingerprintResultReceived(action)
}
is AccountSecurityAction.Internal.PolicyUpdateReceive -> {
handlePolicyUpdateReceive(action)
}
}
}
@ -308,6 +332,20 @@ class AccountSecurityViewModel @Inject constructor(
)
}
}
private fun handlePolicyUpdateReceive(
action: AccountSecurityAction.Internal.PolicyUpdateReceive,
) {
// The vault timeout policy can only be implemented in organizations that have
// the single organization policy, meaning that if this is enabled, the user is
// only in one organization and hence there is only one result in the list.
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = action.vaultTimeoutPolicies?.firstOrNull()?.minutes,
vaultTimeoutPolicyAction = action.vaultTimeoutPolicies?.firstOrNull()?.action,
)
}
}
}
/**
@ -322,6 +360,8 @@ data class AccountSecurityState(
val isUnlockWithPinEnabled: Boolean,
val vaultTimeout: VaultTimeout,
val vaultTimeoutAction: VaultTimeoutAction,
val vaultTimeoutPolicyMinutes: Int?,
val vaultTimeoutPolicyAction: String?,
) : Parcelable
/**
@ -349,14 +389,6 @@ sealed class AccountSecurityDialog : Parcelable {
) : AccountSecurityDialog()
}
/**
* A representation of the Session timeout action.
*/
enum class SessionTimeoutAction(val text: Text) {
LOCK(text = R.string.lock.asText()),
LOG_OUT(text = R.string.log_out.asText()),
}
/**
* Models events for the account security screen.
*/
@ -569,5 +601,12 @@ sealed class AccountSecurityAction {
data class FingerprintResultReceive(
val fingerprintResult: UserFingerprintResult,
) : Internal()
/**
* A policy update has been received.
*/
data class PolicyUpdateReceive(
val vaultTimeoutPolicies: List<PolicyInformation.VaultTimeout>?,
) : Internal()
}
}

View file

@ -22,3 +22,25 @@ val VaultTimeout.Type.displayLabel: Text
VaultTimeout.Type.CUSTOM -> R.string.custom
}
.asText()
/**
* The value in minutes for the given [VaultTimeout.Type], used as a comparison
* against the maximum timeout allowed by the organization's policy.
*/
@Suppress("MagicNumber")
val VaultTimeout.Type.minutes: Int
get() = when (this) {
VaultTimeout.Type.IMMEDIATELY -> 0
VaultTimeout.Type.ONE_MINUTE -> 1
VaultTimeout.Type.FIVE_MINUTES -> 5
VaultTimeout.Type.FIFTEEN_MINUTES -> 15
VaultTimeout.Type.THIRTY_MINUTES -> 30
VaultTimeout.Type.ONE_HOUR -> 60
VaultTimeout.Type.FOUR_HOURS -> 240
VaultTimeout.Type.ON_APP_RESTART,
VaultTimeout.Type.NEVER,
-> Int.MAX_VALUE
VaultTimeout.Type.CUSTOM -> 0
}

View file

@ -5,9 +5,9 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@ -46,21 +46,7 @@ class SyncResponseJsonExtensionsTest {
}
@Test
@OptIn(ExperimentalSerializationApi::class)
fun `policyInformation converts the Json data to policy information`() {
val masterPasswordData = buildJsonObject {
put(key = "minLength", value = 10)
put(key = "minComplexity", value = 3)
put(key = "requireUpper", value = null)
put(key = "requireLower", value = null)
put(key = "requireNumbers", value = true)
put(key = "requireSpecial", value = null)
put(key = "enforceOnLogin", value = true)
}
val masterPasswordPolicy = createMockPolicy(
type = PolicyTypeJson.MASTER_PASSWORD,
data = masterPasswordData,
)
fun `policyInformation converts the MasterPassword Json data to policy information`() {
val policyInformation = PolicyInformation.MasterPassword(
minLength = 10,
minComplexity = 3,
@ -70,10 +56,57 @@ class SyncResponseJsonExtensionsTest {
requireSpecial = null,
enforceOnLogin = true,
)
val policy = createMockPolicy(
type = PolicyTypeJson.MASTER_PASSWORD,
data = Json.encodeToJsonElement(policyInformation).jsonObject,
)
assertEquals(
policyInformation,
masterPasswordPolicy.policyInformation,
policy.policyInformation,
)
}
@Test
fun `policyInformation converts the PasswordGenerator Json data to policy information`() {
val policyInformation = PolicyInformation.PasswordGenerator(
defaultType = "password",
minLength = null,
useUpper = true,
useLower = true,
useNumbers = null,
useSpecial = null,
minNumbers = null,
minSpecial = null,
minNumberWords = 4,
capitalize = true,
includeNumber = null,
)
val policy = createMockPolicy(
type = PolicyTypeJson.PASSWORD_GENERATOR,
data = Json.encodeToJsonElement(policyInformation).jsonObject,
)
assertEquals(
policyInformation,
policy.policyInformation,
)
}
@Test
fun `policyInformation converts the VaultTimeout Json data to policy information`() {
val policyInformation = PolicyInformation.VaultTimeout(
minutes = 10,
action = "lock",
)
val policy = createMockPolicy(
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
data = Json.encodeToJsonElement(policyInformation).jsonObject,
)
assertEquals(
policyInformation,
policy.policyInformation,
)
}

View file

@ -11,13 +11,17 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
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.sdk.VaultSdkSource
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
@ -46,6 +50,12 @@ class SettingsRepositoryTest {
private val fakeSettingsDiskSource = FakeSettingsDiskSource()
private val vaultSdkSource: VaultSdkSource = mockk()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
} returns mutableActivePolicyFlow
}
private val settingsRepository = SettingsRepositoryImpl(
autofillManager = autofillManager,
@ -55,6 +65,7 @@ class SettingsRepositoryTest {
vaultSdkSource = vaultSdkSource,
biometricsEncryptionManager = biometricsEncryptionManager,
dispatcherManager = FakeDispatcherManager(),
policyManager = policyManager,
)
@Test

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasTextExactly
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
@ -634,6 +635,35 @@ class AccountSecurityScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText("Unlock with PIN code").assertIsOn()
}
@Test
fun `session timeout policy warning should update according to state`() {
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
)
}
val timeOnlyText = "Your organization policies have set your maximum allowed " +
"vault timeout to 1 hour(s) and 40 minute(s)."
composeTestRule
.onNodeWithText(timeOnlyText)
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
vaultTimeoutPolicyAction = "lock",
)
}
val bothText = "Your organization policies are affecting your vault timeout. " +
"Maximum allowed vault timeout is 1 hour(s) and 40 minute(s). Your vault " +
"timeout action is set to Lock."
composeTestRule
.onNodeWithText(bothText)
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `session timeout should be updated on or off according to state`() {
composeTestRule
@ -708,6 +738,66 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.assertIsDisplayed()
}
@Test
fun `on session timeout click should update according to state`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
)
}
composeTestRule
.onAllNodesWithText("Session timeout")
.filterToOne(hasClickAction())
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Immediately")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("1 minute")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("5 minutes")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("15 minutes")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("30 minutes")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("1 hour")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("4 hours")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("On app restart")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Never")
.assertDoesNotExist()
composeTestRule
.onAllNodesWithText("Custom")
.filterToOne(hasAnyAncestor(isDialog()))
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on session timeout selection dialog cancel click should close the dialog`() {
composeTestRule.assertNoDialogExists()
@ -960,6 +1050,47 @@ class AccountSecurityScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `custom session timeout dialog Ok click should dismiss the dialog and show an error if value exceeds policy limit`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123),
vaultTimeoutPolicyMinutes = 100,
)
}
composeTestRule
.onNode(hasTextExactly("Custom", "02:03"))
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onNodeWithText("Your vault timeout exceeds the restrictions set by your organization.")
.assert(hasAnyAncestor(isDialog()))
.isDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
AccountSecurityAction.CustomVaultTimeoutSelect(
VaultTimeout.Custom(vaultTimeoutInMinutes = 100),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on session timeout action click should show a selection dialog`() {
composeTestRule.assertNoDialogExists()
@ -1354,6 +1485,8 @@ class AccountSecurityScreenTest : BaseComposeTest() {
isUnlockWithPinEnabled = false,
vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
)
}
}

View file

@ -4,7 +4,9 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
@ -12,6 +14,10 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
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 com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -23,6 +29,9 @@ import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@ -41,6 +50,12 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
}
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val policyManager: PolicyManager = mockk {
every {
getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
} returns mutableActivePolicyFlow
}
@Test
fun `initial state should be correct when saved state is set`() {
@ -60,6 +75,35 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
coVerify { settingsRepository.getUserFingerprint() }
}
@Test
fun `state updates when policies change`() = runTest {
val viewModel = createViewModel()
val policyInformation = PolicyInformation.VaultTimeout(
minutes = 10,
action = "lock",
)
mutableActivePolicyFlow.emit(
listOf(
createMockPolicy(
isEnabled = true,
type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT,
data = Json.encodeToJsonElement(policyInformation).jsonObject,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
vaultTimeoutPolicyMinutes = 10,
vaultTimeoutPolicyAction = "lock",
),
awaitItem(),
)
}
}
@Test
fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest {
val fingerprint = "fingerprint"
@ -488,17 +532,20 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
}
@Suppress("LongParameterList")
private fun createViewModel(
initialState: AccountSecurityState? = DEFAULT_STATE,
authRepository: AuthRepository = this.authRepository,
vaultRepository: VaultRepository = this.vaultRepository,
environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository,
settingsRepository: SettingsRepository = this.settingsRepository,
policyManager: PolicyManager = this.policyManager,
): AccountSecurityViewModel = AccountSecurityViewModel(
authRepository = authRepository,
vaultRepository = vaultRepository,
settingsRepository = settingsRepository,
environmentRepository = environmentRepository,
policyManager = policyManager,
savedStateHandle = SavedStateHandle().apply {
set("state", initialState)
},
@ -515,4 +562,6 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
isUnlockWithPinEnabled = false,
vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
)

View file

@ -28,4 +28,26 @@ class VaultTimeoutExtensionsTest {
)
}
}
@Test
fun `minutes should return the correct value for each type`() {
mapOf(
VaultTimeout.Type.IMMEDIATELY to 0,
VaultTimeout.Type.ONE_MINUTE to 1,
VaultTimeout.Type.FIVE_MINUTES to 5,
VaultTimeout.Type.FIFTEEN_MINUTES to 15,
VaultTimeout.Type.THIRTY_MINUTES to 30,
VaultTimeout.Type.ONE_HOUR to 60,
VaultTimeout.Type.FOUR_HOURS to 240,
VaultTimeout.Type.ON_APP_RESTART to Int.MAX_VALUE,
VaultTimeout.Type.NEVER to Int.MAX_VALUE,
VaultTimeout.Type.CUSTOM to 0,
)
.forEach { (type, value) ->
assertEquals(
value,
type.minutes,
)
}
}
}