BITAU-107 Add Feature Flag and UI for Authenticator Syncing (#3847)

This commit is contained in:
Andrew Haisting 2024-09-05 13:13:48 -05:00 committed by GitHub
parent 69ca7649e2
commit b1ecf125d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 243 additions and 13 deletions

View file

@ -25,6 +25,7 @@ sealed class FlagKey<out T : Any> {
*/ */
val activeFlags: List<FlagKey<*>> by lazy { val activeFlags: List<FlagKey<*>> by lazy {
listOf( listOf(
AuthenticatorSync,
EmailVerification, EmailVerification,
OnboardingFlow, OnboardingFlow,
OnboardingCarousel, OnboardingCarousel,
@ -32,6 +33,15 @@ sealed class FlagKey<out T : Any> {
} }
} }
/**
* Data object holding the key for syncing with the Bitwarden Authenticator app.
*/
data object AuthenticatorSync : FlagKey<Boolean>() {
override val keyName: String = "authenticator-sync"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/** /**
* Data object holding the key for Email Verification feature. * Data object holding the key for Email Verification feature.
*/ */

View file

@ -37,6 +37,11 @@ interface SettingsRepository {
*/ */
var initialAutofillDialogShown: Boolean var initialAutofillDialogShown: Boolean
/**
* Whether the user has enabled syncing with the Bitwarden Authenticator app.
*/
var isAuthenticatorSyncEnabled: Boolean
/** /**
* The currently stored last time the vault was synced. * The currently stored last time the vault was synced.
*/ */

View file

@ -99,6 +99,9 @@ class SettingsRepositoryImpl(
} }
?: MutableStateFlow(value = null) ?: MutableStateFlow(value = null)
// TODO: this should be backed by disk and should set and clear the sync key (BITAU-103)
override var isAuthenticatorSyncEnabled: Boolean = false
override var isIconLoadingDisabled: Boolean override var isIconLoadingDisabled: Boolean
get() = settingsDiskSource.isIconLoadingDisabled ?: false get() = settingsDiskSource.isIconLoadingDisabled ?: false
set(value) { set(value) {

View file

@ -28,13 +28,15 @@ class DebugMenuViewModel @Inject constructor(
init { init {
combine( combine(
featureFlagManager.getFeatureFlagFlow(FlagKey.AuthenticatorSync),
featureFlagManager.getFeatureFlagFlow(FlagKey.EmailVerification), featureFlagManager.getFeatureFlagFlow(FlagKey.EmailVerification),
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingCarousel), featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingCarousel),
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow), featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
) { (emailVerification, onboardingCarousel, onboardingFlow) -> ) { (authenticatorSync, emailVerification, onboardingCarousel, onboardingFlow) ->
sendAction( sendAction(
DebugMenuAction.Internal.UpdateFeatureFlagMap( DebugMenuAction.Internal.UpdateFeatureFlagMap(
mapOf( mapOf(
FlagKey.AuthenticatorSync to authenticatorSync,
FlagKey.EmailVerification to emailVerification, FlagKey.EmailVerification to emailVerification,
FlagKey.OnboardingCarousel to onboardingCarousel, FlagKey.OnboardingCarousel to onboardingCarousel,
FlagKey.OnboardingFlow to onboardingFlow, FlagKey.OnboardingFlow to onboardingFlow,

View file

@ -22,6 +22,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.DummyString, FlagKey.DummyString,
-> Unit -> Unit
FlagKey.AuthenticatorSync,
FlagKey.EmailVerification, FlagKey.EmailVerification,
FlagKey.OnboardingCarousel, FlagKey.OnboardingCarousel,
FlagKey.OnboardingFlow, FlagKey.OnboardingFlow,
@ -62,6 +63,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.DummyString, FlagKey.DummyString,
-> this.keyName -> this.keyName
FlagKey.AuthenticatorSync -> stringResource(R.string.authenticator_sync)
FlagKey.EmailVerification -> stringResource(R.string.email_verification) FlagKey.EmailVerification -> stringResource(R.string.email_verification)
FlagKey.OnboardingCarousel -> stringResource(R.string.onboarding_carousel) FlagKey.OnboardingCarousel -> stringResource(R.string.onboarding_carousel)
FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow) FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow)

View file

@ -54,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
@ -71,7 +72,7 @@ private const val MINUTES_PER_HOUR = 60
/** /**
* Displays the account security screen. * Displays the account security screen.
*/ */
@Suppress("LongMethod", "LongParameterList") @Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AccountSecurityScreen( fun AccountSecurityScreen(
@ -229,6 +230,19 @@ fun AccountSecurityScreen(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (state.shouldShowEnableAuthenticatorSync) {
SyncWithAuthenticatorRow(
isChecked = state.isAuthenticatorSyncChecked,
onCheckedChange = remember(viewModel) {
{
viewModel.trySendAction(
AccountSecurityAction.AuthenticatorSyncToggle(enabled = it),
)
}
},
)
Spacer(Modifier.height(16.dp))
}
BitwardenListHeaderText( BitwardenListHeaderText(
label = stringResource(id = R.string.session_timeout), label = stringResource(id = R.string.session_timeout),
modifier = Modifier modifier = Modifier
@ -645,6 +659,27 @@ private fun SessionTimeoutActionRow(
} }
} }
@Composable
private fun SyncWithAuthenticatorRow(
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
BitwardenListHeaderText(
label = stringResource(R.string.authenticator_sync),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenWideSwitch(
label = stringResource(R.string.allow_bitwarden_authenticator_syncing),
onCheckedChange = onCheckedChange,
isChecked = isChecked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
@Composable @Composable
private fun FingerPrintPhraseDialog( private fun FingerPrintPhraseDialog(
fingerprintPhrase: Text, fingerprintPhrase: Text,

View file

@ -9,7 +9,9 @@ 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.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository 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.BiometricsKeyResult
@ -45,6 +47,7 @@ class AccountSecurityViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val environmentRepository: EnvironmentRepository, private val environmentRepository: EnvironmentRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager, private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val featureFlagManager: FeatureFlagManager,
policyManager: PolicyManager, policyManager: PolicyManager,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>( ) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
@ -57,6 +60,7 @@ class AccountSecurityViewModel @Inject constructor(
AccountSecurityState( AccountSecurityState(
dialog = null, dialog = null,
fingerprintPhrase = "".asText(), // This will be filled in dynamically fingerprintPhrase = "".asText(), // This will be filled in dynamically
isAuthenticatorSyncChecked = settingsRepository.isAuthenticatorSyncEnabled,
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled && isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
isBiometricsValid, isBiometricsValid,
isUnlockWithPasswordEnabled = authRepository isUnlockWithPasswordEnabled = authRepository
@ -65,6 +69,9 @@ class AccountSecurityViewModel @Inject constructor(
?.activeAccount ?.activeAccount
?.hasMasterPassword != false, ?.hasMasterPassword != false,
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
shouldShowEnableAuthenticatorSync = featureFlagManager.getFeatureFlag(
key = FlagKey.AuthenticatorSync,
),
userId = userId, userId = userId,
vaultTimeout = settingsRepository.vaultTimeout, vaultTimeout = settingsRepository.vaultTimeout,
vaultTimeoutAction = settingsRepository.vaultTimeoutAction, vaultTimeoutAction = settingsRepository.vaultTimeoutAction,
@ -99,6 +106,13 @@ class AccountSecurityViewModel @Inject constructor(
.onEach(::sendAction) .onEach(::sendAction)
.launchIn(viewModelScope) .launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.AuthenticatorSync)
.onEach {
trySendAction(AccountSecurityAction.Internal.AuthenticatorSyncFeatureFlagUpdate(it))
}
.launchIn(viewModelScope)
viewModelScope.launch { viewModelScope.launch {
trySendAction( trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive( AccountSecurityAction.Internal.FingerprintResultReceive(
@ -110,6 +124,7 @@ class AccountSecurityViewModel @Inject constructor(
override fun handleAction(action: AccountSecurityAction): Unit = when (action) { override fun handleAction(action: AccountSecurityAction): Unit = when (action) {
AccountSecurityAction.AccountFingerprintPhraseClick -> handleAccountFingerprintPhraseClick() AccountSecurityAction.AccountFingerprintPhraseClick -> handleAccountFingerprintPhraseClick()
is AccountSecurityAction.AuthenticatorSyncToggle -> handleAuthenticatorSyncToggle(action)
AccountSecurityAction.BackClick -> handleBackClick() AccountSecurityAction.BackClick -> handleBackClick()
AccountSecurityAction.ChangeMasterPasswordClick -> handleChangeMasterPasswordClick() AccountSecurityAction.ChangeMasterPasswordClick -> handleChangeMasterPasswordClick()
AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick() AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
@ -137,6 +152,13 @@ class AccountSecurityViewModel @Inject constructor(
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) } mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) }
} }
private fun handleAuthenticatorSyncToggle(
action: AccountSecurityAction.AuthenticatorSyncToggle,
) {
settingsRepository.isAuthenticatorSyncEnabled = action.enabled
mutableStateFlow.update { it.copy(isAuthenticatorSyncChecked = action.enabled) }
}
private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack) private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack)
private fun handleChangeMasterPasswordClick() { private fun handleChangeMasterPasswordClick() {
@ -292,6 +314,10 @@ class AccountSecurityViewModel @Inject constructor(
private fun handleInternalAction(action: AccountSecurityAction.Internal) { private fun handleInternalAction(action: AccountSecurityAction.Internal) {
when (action) { when (action) {
is AccountSecurityAction.Internal.AuthenticatorSyncFeatureFlagUpdate -> {
handleAuthenticatorSyncFeatureFlagUpdate(action)
}
is AccountSecurityAction.Internal.BiometricsKeyResultReceive -> { is AccountSecurityAction.Internal.BiometricsKeyResultReceive -> {
handleBiometricsKeyResultReceive(action) handleBiometricsKeyResultReceive(action)
} }
@ -306,6 +332,16 @@ class AccountSecurityViewModel @Inject constructor(
} }
} }
private fun handleAuthenticatorSyncFeatureFlagUpdate(
action: AccountSecurityAction.Internal.AuthenticatorSyncFeatureFlagUpdate,
) {
mutableStateFlow.update {
it.copy(
shouldShowEnableAuthenticatorSync = action.isEnabled,
)
}
}
private fun handleBiometricsKeyResultReceive( private fun handleBiometricsKeyResultReceive(
action: AccountSecurityAction.Internal.BiometricsKeyResultReceive, action: AccountSecurityAction.Internal.BiometricsKeyResultReceive,
) { ) {
@ -377,9 +413,11 @@ class AccountSecurityViewModel @Inject constructor(
data class AccountSecurityState( data class AccountSecurityState(
val dialog: AccountSecurityDialog?, val dialog: AccountSecurityDialog?,
val fingerprintPhrase: Text, val fingerprintPhrase: Text,
val isAuthenticatorSyncChecked: Boolean,
val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean,
val isUnlockWithPasswordEnabled: Boolean, val isUnlockWithPasswordEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean, val isUnlockWithPinEnabled: Boolean,
val shouldShowEnableAuthenticatorSync: Boolean,
val userId: String, val userId: String,
val vaultTimeout: VaultTimeout, val vaultTimeout: VaultTimeout,
val vaultTimeoutAction: VaultTimeoutAction, val vaultTimeoutAction: VaultTimeoutAction,
@ -493,6 +531,13 @@ sealed class AccountSecurityAction {
*/ */
data object AccountFingerprintPhraseClick : AccountSecurityAction() data object AccountFingerprintPhraseClick : AccountSecurityAction()
/**
* User clicked the authenticator sync toggle.
*/
data class AuthenticatorSyncToggle(
val enabled: Boolean,
) : AccountSecurityAction()
/** /**
* User clicked back button. * User clicked back button.
*/ */
@ -592,6 +637,14 @@ sealed class AccountSecurityAction {
* Models actions that can be sent by the view model itself. * Models actions that can be sent by the view model itself.
*/ */
sealed class Internal : AccountSecurityAction() { sealed class Internal : AccountSecurityAction() {
/**
* The feature flag value for authenticator syncing was updated.
*/
data class AuthenticatorSyncFeatureFlagUpdate(
val isEnabled: Boolean,
) : Internal()
/** /**
* A biometrics key result has been received. * A biometrics key result has been received.
*/ */

View file

@ -992,4 +992,6 @@ Do you want to switch to this account?</string>
<string name="expired_link">Expired link</string> <string name="expired_link">Expired link</string>
<string name="please_restart_registration_or_try_logging_in">Please restart registration or try logging in. You may already have an account.</string> <string name="please_restart_registration_or_try_logging_in">Please restart registration or try logging in. You may already have an account.</string>
<string name="restart_registration">Restart registration</string> <string name="restart_registration">Restart registration</string>
<string name="authenticator_sync">Authenticator Sync</string>
<string name="allow_bitwarden_authenticator_syncing">Allow Bitwarden Authenticator Syncing</string>
</resources> </resources>

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
class FlagKeyTest {
@Test
fun `AuthenticatorSync default value should be false`() {
assertFalse(FlagKey.AuthenticatorSync.defaultValue)
}
}

View file

@ -1014,6 +1014,18 @@ class SettingsRepositoryTest {
settingsRepository.initialAutofillDialogShown = false settingsRepository.initialAutofillDialogShown = false
assertEquals(false, fakeSettingsDiskSource.initialAutofillDialogShown) assertEquals(false, fakeSettingsDiskSource.initialAutofillDialogShown)
} }
@Test
fun `isAuthenticatorSyncEnabled should default to false`() {
assertFalse(settingsRepository.isAuthenticatorSyncEnabled)
}
@Test
fun `isAuthenticatorSyncEnabled should be kept in memory and update accordingly`() = runTest {
assertFalse(settingsRepository.isAuthenticatorSyncEnabled)
settingsRepository.isAuthenticatorSyncEnabled = true
assertTrue(settingsRepository.isAuthenticatorSyncEnabled)
}
} }
private const val USER_ID: String = "userId" private const val USER_ID: String = "userId"

View file

@ -76,12 +76,14 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
} }
private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf( private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.AuthenticatorSync to true,
FlagKey.EmailVerification to true, FlagKey.EmailVerification to true,
FlagKey.OnboardingCarousel to true, FlagKey.OnboardingCarousel to true,
FlagKey.OnboardingFlow to true, FlagKey.OnboardingFlow to true,
) )
private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf( private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.AuthenticatorSync to false,
FlagKey.EmailVerification to false, FlagKey.EmailVerification to false,
FlagKey.OnboardingCarousel to true, FlagKey.OnboardingCarousel to true,
FlagKey.OnboardingFlow to false, FlagKey.OnboardingFlow to false,

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasTextExactly import androidx.compose.ui.test.hasTextExactly
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -74,6 +75,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
private val viewModel = mockk<AccountSecurityViewModel>(relaxed = true) { private val viewModel = mockk<AccountSecurityViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
every { trySendAction(any()) } just runs
} }
@Before @Before
@ -1457,6 +1459,35 @@ class AccountSecurityScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText(rowText).assertDoesNotExist() composeTestRule.onNodeWithText(rowText).assertDoesNotExist()
} }
@Test
fun `sync with Bitwarden authenticator UI should be displayed according to state`() {
val toggleText = "Allow Bitwarden Authenticator Syncing"
composeTestRule.onNodeWithText(toggleText).assertDoesNotExist()
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowEnableAuthenticatorSync = true) }
composeTestRule.onNodeWithText(toggleText).performScrollTo().assertIsDisplayed()
composeTestRule.onAllNodesWithText(toggleText).filterToOne(isToggleable()).assertIsOff()
mutableStateFlow.update {
DEFAULT_STATE.copy(
shouldShowEnableAuthenticatorSync = true,
isAuthenticatorSyncChecked = true,
)
}
composeTestRule.onNodeWithText(toggleText).assertIsDisplayed()
composeTestRule.onAllNodesWithText(toggleText).filterToOne(isToggleable()).assertIsOn()
}
@Test
fun `sync with Bitwarden authenticator click should send AuthenticatorSyncToggle action`() {
mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowEnableAuthenticatorSync = true) }
composeTestRule
.onNodeWithText("Allow Bitwarden Authenticator Syncing")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.AuthenticatorSyncToggle(true)) }
}
} }
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()
@ -1464,10 +1495,12 @@ private const val USER_ID: String = "activeUserId"
private val DEFAULT_STATE = AccountSecurityState( private val DEFAULT_STATE = AccountSecurityState(
dialog = null, dialog = null,
fingerprintPhrase = "fingerprint-placeholder".asText(), fingerprintPhrase = "fingerprint-placeholder".asText(),
isAuthenticatorSyncChecked = false,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithPinEnabled = false, isUnlockWithPinEnabled = false,
userId = USER_ID, userId = USER_ID,
shouldShowEnableAuthenticatorSync = false,
vaultTimeout = VaultTimeout.ThirtyMinutes, vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,

View file

@ -8,7 +8,9 @@ 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.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository 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.BiometricsKeyResult
@ -24,6 +26,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.AccountSecurityAction.AuthenticatorSyncToggle
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@ -32,6 +35,7 @@ import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
@ -49,6 +53,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
private val vaultRepository: VaultRepository = mockk(relaxed = true) private val vaultRepository: VaultRepository = mockk(relaxed = true)
private val settingsRepository: SettingsRepository = mockk { private val settingsRepository: SettingsRepository = mockk {
every { isAuthenticatorSyncEnabled } returns false
every { isUnlockWithBiometricsEnabled } returns false every { isUnlockWithBiometricsEnabled } returns false
every { isUnlockWithPinEnabled } returns false every { isUnlockWithPinEnabled } returns false
every { vaultTimeout } returns VaultTimeout.ThirtyMinutes every { vaultTimeout } returns VaultTimeout.ThirtyMinutes
@ -58,12 +63,22 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>() private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk { private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
every { createCipherOrNull(DEFAULT_USER_STATE.activeUserId) } returns CIPHER every { createCipherOrNull(DEFAULT_USER_STATE.activeUserId) } returns CIPHER
every { getOrCreateCipher(DEFAULT_USER_STATE.activeUserId) } returns CIPHER
every {
isBiometricIntegrityValid(
userId = DEFAULT_USER_STATE.activeUserId,
cipher = CIPHER,
)
} returns true
} }
private val policyManager: PolicyManager = mockk { private val policyManager: PolicyManager = mockk {
every { every {
getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
} returns mutableActivePolicyFlow } returns mutableActivePolicyFlow
} }
private val featureFlagManager: FeatureFlagManager = mockk(relaxed = true) {
every { getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
}
@Test @Test
fun `initial state should be correct when saved state is set`() { fun `initial state should be correct when saved state is set`() {
@ -74,19 +89,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
@Test @Test
fun `initial state should be correct when saved state is not set`() { fun `initial state should be correct when saved state is not set`() {
every { settingsRepository.isUnlockWithPinEnabled } returns true
every {
biometricsEncryptionManager.getOrCreateCipher(DEFAULT_USER_STATE.activeUserId)
} returns CIPHER
every {
biometricsEncryptionManager.isBiometricIntegrityValid(
userId = DEFAULT_USER_STATE.activeUserId,
cipher = CIPHER,
)
} returns true
val viewModel = createViewModel(initialState = null) val viewModel = createViewModel(initialState = null)
assertEquals( assertEquals(
DEFAULT_STATE.copy(isUnlockWithPinEnabled = true), DEFAULT_STATE,
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
verify { verify {
@ -128,6 +133,20 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
} }
@Suppress("MaxLineLength")
@Test
fun `on AuthenticatorSyncToggle should update SettingsRepository and isAuthenticatorSyncChecked`() =
runTest {
val viewModel = createViewModel()
every { settingsRepository.isAuthenticatorSyncEnabled = true } just runs
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AuthenticatorSyncToggle(enabled = true))
assertEquals(DEFAULT_STATE.copy(isAuthenticatorSyncChecked = true), awaitItem())
}
verify { settingsRepository.isAuthenticatorSyncEnabled = true }
}
@Test @Test
fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest { fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest {
val fingerprint = "fingerprint" val fingerprint = "fingerprint"
@ -597,10 +616,46 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
} }
@Suppress("MaxLineLength")
@Test
fun `when featureFlagManger returns true for AuthenticatorSync, should show authenticator sync UI`() {
val vm = createViewModel(
initialState = null,
featureFlagManager = mockk {
every { getFeatureFlag(FlagKey.AuthenticatorSync) } returns true
every { getFeatureFlagFlow(FlagKey.AuthenticatorSync) } returns emptyFlow()
},
)
assertEquals(
DEFAULT_STATE.copy(shouldShowEnableAuthenticatorSync = true),
vm.stateFlow.value,
)
}
@Test
fun `when featureFlagManger updates value AuthenticatorSync, should update UI`() = runTest {
val featureFlagFlow = MutableStateFlow(false)
val vm = createViewModel(
initialState = null,
featureFlagManager = mockk {
every { getFeatureFlag(FlagKey.AuthenticatorSync) } returns false
every { getFeatureFlagFlow(FlagKey.AuthenticatorSync) } returns featureFlagFlow
},
)
vm.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
featureFlagFlow.value = true
assertEquals(DEFAULT_STATE.copy(shouldShowEnableAuthenticatorSync = true), awaitItem())
featureFlagFlow.value = false
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
private fun createViewModel( private fun createViewModel(
initialState: AccountSecurityState? = DEFAULT_STATE, initialState: AccountSecurityState? = DEFAULT_STATE,
authRepository: AuthRepository = this.authRepository, authRepository: AuthRepository = this.authRepository,
featureFlagManager: FeatureFlagManager = this.featureFlagManager,
vaultRepository: VaultRepository = this.vaultRepository, vaultRepository: VaultRepository = this.vaultRepository,
environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository, environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository,
settingsRepository: SettingsRepository = this.settingsRepository, settingsRepository: SettingsRepository = this.settingsRepository,
@ -608,6 +663,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
policyManager: PolicyManager = this.policyManager, policyManager: PolicyManager = this.policyManager,
): AccountSecurityViewModel = AccountSecurityViewModel( ): AccountSecurityViewModel = AccountSecurityViewModel(
authRepository = authRepository, authRepository = authRepository,
featureFlagManager = featureFlagManager,
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
environmentRepository = environmentRepository, environmentRepository = environmentRepository,
@ -648,6 +704,7 @@ private val DEFAULT_USER_STATE = UserState(
private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState( private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
dialog = null, dialog = null,
fingerprintPhrase = FINGERPRINT.asText(), fingerprintPhrase = FINGERPRINT.asText(),
isAuthenticatorSyncChecked = false,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithPinEnabled = false, isUnlockWithPinEnabled = false,
@ -656,4 +713,5 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null, vaultTimeoutPolicyAction = null,
shouldShowEnableAuthenticatorSync = false,
) )