PM-10619 screen to generate master password (#3721)

This commit is contained in:
Dave Severns
2024-08-13 16:58:51 -04:00
committed by GitHub
parent e3371b7620
commit 151b081161
9 changed files with 796 additions and 3 deletions

View File

@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDest
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn
import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination
import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.masterPasswordGeneratorDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator.navigateToMasterPasswordGenerator
import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination
import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding
@@ -23,6 +25,7 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDe
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordguidance.masterPasswordGuidanceDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.navigateToPreventAccountLockout
import com.x8bit.bitwarden.ui.auth.feature.preventaccountlockout.preventAccountLockoutDestination
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
@@ -127,13 +130,15 @@ fun NavGraphBuilder.authGraph(
)
masterPasswordGuidanceDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToGeneratePassword = {
// TODO [PM-10619](https://bitwarden.atlassian.net/browse/PM-10619)
},
onNavigateToGeneratePassword = { navController.navigateToMasterPasswordGenerator() },
)
preventAccountLockoutDestination(
onNavigateBack = { navController.popBackStack() },
)
masterPasswordGeneratorDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToPreventLockout = { navController.navigateToPreventAccountLockout() },
)
}
}

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val MASTER_PASSWORD_GENERATOR = "master_password_generator"
/**
* Navigate to master password generator screen.
*/
fun NavController.navigateToMasterPasswordGenerator(navOptions: NavOptions? = null) {
this.navigate(MASTER_PASSWORD_GENERATOR, navOptions)
}
/**
* Add the master password generator screen to the nav graph.
*/
fun NavGraphBuilder.masterPasswordGeneratorDestination(
onNavigateBack: () -> Unit,
onNavigateToPreventLockout: () -> Unit,
) {
composableWithSlideTransitions(
route = MASTER_PASSWORD_GENERATOR,
) {
MasterPasswordGeneratorScreen(
onNavigateBack = onNavigateBack,
onNavigateToPreventLockout = onNavigateToPreventLockout,
)
}
}

View File

@@ -0,0 +1,224 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
/**
* Top level composable for the master password generator.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun MasterPasswordGeneratorScreen(
onNavigateBack: () -> Unit,
onNavigateToPreventLockout: () -> Unit,
viewModel: MasterPasswordGeneratorViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
val snackbarHostState = remember {
SnackbarHostState()
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
MasterPasswordGeneratorEvent.NavigateBack -> onNavigateBack()
MasterPasswordGeneratorEvent.NavigateToPreventLockout -> onNavigateToPreventLockout()
is MasterPasswordGeneratorEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(
message = event.text.toString(resources),
duration = SnackbarDuration.Short,
)
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MasterPasswordGeneratorTopBar(
scrollBehavior = scrollBehavior,
onBackClick = remember(viewModel) {
{
viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction)
}
},
onSaveClick = remember(viewModel) {
{
viewModel.trySendAction(
MasterPasswordGeneratorAction.SavePasswordClickAction,
)
}
},
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(innerPadding),
) {
MasterPasswordGeneratorContent(
generatedPassword = state.generatedPassword,
onGenerateNewPassword = remember(viewModel) {
{
viewModel.trySendAction(
MasterPasswordGeneratorAction.GeneratePasswordClickAction,
)
}
},
onLearnToPreventLockout = remember(viewModel) {
{
viewModel.trySendAction(
MasterPasswordGeneratorAction.PreventLockoutClickAction,
)
}
},
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun MasterPasswordGeneratorContent(
generatedPassword: String,
onGenerateNewPassword: () -> Unit,
onLearnToPreventLockout: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
) {
Spacer(modifier = Modifier.height(24.dp))
BitwardenTextField(
label = "",
value = generatedPassword,
onValueChange = {},
readOnly = true,
shouldAddCustomLineBreaks = true,
textStyle = LocalNonMaterialTypography.current.sensitiveInfoSmall,
visualTransformation = nonLetterColorVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenFilledButtonWithIcon(
label = stringResource(R.string.generate_button_label),
onClick = onGenerateNewPassword,
icon = rememberVectorPainter(id = R.drawable.ic_generator),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.write_this_password_down_and_keep_it_somewhere_safe),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
BitwardenClickableText(
label = stringResource(R.string.learn_about_other_ways_to_prevent_account_lockout),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
onClick = onLearnToPreventLockout,
innerPadding = PaddingValues(horizontal = 0.dp, vertical = 4.dp),
)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun MasterPasswordGeneratorTopBar(
scrollBehavior: TopAppBarScrollBehavior,
onBackClick: () -> Unit,
onSaveClick: () -> Unit,
) {
BitwardenTopAppBar(
title = stringResource(R.string.generate_master_password),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = onBackClick,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
labelTextColor = MaterialTheme.colorScheme.primary,
onClick = onSaveClick,
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
private fun MasterPasswordGeneratorTopBarPreview() {
BitwardenTheme {
MasterPasswordGeneratorTopBar(
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
onBackClick = { },
onSaveClick = { },
)
}
}
@Preview(showBackground = true)
@Composable
private fun MasterPasswordGeneratorContentPreview() {
BitwardenTheme {
MasterPasswordGeneratorContent(
generatedPassword = "really-secure-password",
onGenerateNewPassword = { },
onLearnToPreventLockout = { },
modifier = Modifier.padding(16.dp),
)
}
}

View File

@@ -0,0 +1,207 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.generators.PassphraseGeneratorRequest
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
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 com.x8bit.bitwarden.ui.tools.feature.generator.util.toStrictestPolicy
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import kotlin.math.max
private const val KEY_STATE = "state"
private const val DEFAULT_SEPARATOR = "-"
private const val DEFAULT_WORD_COUNT = 3
/**
* ViewModel to support the [MasterPasswordGeneratorScreen]
*/
@HiltViewModel
@Suppress("MaxLineLength")
class MasterPasswordGeneratorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val generatorRepository: GeneratorRepository,
private val policyManager: PolicyManager,
) : BaseViewModel<MasterPasswordGeneratorState, MasterPasswordGeneratorEvent, MasterPasswordGeneratorAction>(
initialState = savedStateHandle[KEY_STATE]
?: MasterPasswordGeneratorState(generatedPassword = DEFAULT_SEPARATOR),
) {
private var generatePasswordJob: Job? = null
private val passphraseRequest = getPolicyBasedPassphraseRequest()
init {
if (state.generatedPassword == DEFAULT_SEPARATOR) {
generateNewPassphrase()
}
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: MasterPasswordGeneratorAction) {
when (action) {
MasterPasswordGeneratorAction.BackClickAction -> handleBackAction()
MasterPasswordGeneratorAction.GeneratePasswordClickAction -> {
handleGeneratePasswordAction()
}
MasterPasswordGeneratorAction.PreventLockoutClickAction -> handlePreventLockoutAction()
MasterPasswordGeneratorAction.SavePasswordClickAction -> handleSavePasswordAction()
is MasterPasswordGeneratorAction.Internal -> {
handleInternalAction(internalAction = action)
}
}
}
private fun handleBackAction() = sendEvent(MasterPasswordGeneratorEvent.NavigateBack)
private fun handleSavePasswordAction() {
// TODO [PM-10692](https://bitwarden.atlassian.net/browse/PM-10692)
}
private fun handlePreventLockoutAction() =
sendEvent(MasterPasswordGeneratorEvent.NavigateToPreventLockout)
private fun handleInternalAction(internalAction: MasterPasswordGeneratorAction.Internal) {
when (internalAction) {
is MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction -> {
handleUpdatedPassphraseResult(internalAction.result)
}
}
}
private fun handleUpdatedPassphraseResult(passphraseResult: GeneratedPassphraseResult) {
when (passphraseResult) {
GeneratedPassphraseResult.InvalidRequest -> {
sendEvent(
MasterPasswordGeneratorEvent.ShowSnackbar(
R.string.an_error_has_occurred.asText(),
),
)
}
is GeneratedPassphraseResult.Success -> {
mutableStateFlow.update {
it.copy(generatedPassword = passphraseResult.generatedString)
}
}
}
}
private fun handleGeneratePasswordAction() = generateNewPassphrase()
private fun generateNewPassphrase() {
generatePasswordJob?.cancel()
generatePasswordJob = viewModelScope.launch {
val result = generatorRepository.generatePassphrase(
passphraseGeneratorRequest = passphraseRequest,
)
sendAction(
MasterPasswordGeneratorAction.Internal.ReceiveUpdatedPassphraseResultAction(
result = result,
),
)
}
}
private fun getPolicyBasedPassphraseRequest(): PassphraseGeneratorRequest {
val policy = policyManager
.getActivePolicies<PolicyInformation.MasterPassword>()
.toStrictestPolicy()
val options = generatorRepository.getPasscodeGenerationOptions()
val optionsWordCount = options?.numWords ?: DEFAULT_WORD_COUNT
return PassphraseGeneratorRequest(
numWords = max(optionsWordCount, DEFAULT_WORD_COUNT).toUByte(),
wordSeparator = options?.wordSeparator ?: DEFAULT_SEPARATOR,
capitalize = policy.requireUpper == true,
includeNumber = policy.requireNumbers == true,
)
}
}
/**
* MasterPasswordGeneratorState
*/
@Parcelize
data class MasterPasswordGeneratorState(
val generatedPassword: String,
) : Parcelable
/**
* Model events to send to the UI
*/
sealed class MasterPasswordGeneratorEvent {
/**
* Navigate back to the previous screen.
*/
data object NavigateBack : MasterPasswordGeneratorEvent()
/**
* Navigate to the prevent account lockout tips screen.
*/
data object NavigateToPreventLockout : MasterPasswordGeneratorEvent()
/**
* Show a Snackbar message.
*/
data class ShowSnackbar(val text: Text) : MasterPasswordGeneratorEvent()
}
/**
* Model actions from the UI and internal sources for the ViewModel to handle.
*/
sealed class MasterPasswordGeneratorAction {
/**
* Internal actions that should only be sent via the owner of the action flow.
* @see [MasterPasswordGeneratorViewModel]
*/
@VisibleForTesting
sealed class Internal : MasterPasswordGeneratorAction() {
/**
* Internal action to indicate a generated password result has been received.
*/
data class ReceiveUpdatedPassphraseResultAction(
val result: GeneratedPassphraseResult,
) : Internal()
}
/**
* Indicate the generate new passphrase button has been clicked.
*/
data object GeneratePasswordClickAction : MasterPasswordGeneratorAction()
/**
* Indicate the prevent lockout link has been clicked.
*/
data object PreventLockoutClickAction : MasterPasswordGeneratorAction()
/**
* Indicate the back arrow button has been clicked.
*/
data object BackClickAction : MasterPasswordGeneratorAction()
/**
* Indicate the save button has been clicked.
*/
data object SavePasswordClickAction : MasterPasswordGeneratorAction()
}

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
/**
* Creates the strictest set of rules based on contents of list of
* [PolicyInformation.MasterPassword].
*/
fun List<PolicyInformation.MasterPassword>.toStrictestPolicy() = PolicyInformation.MasterPassword(
minLength = mapNotNull { it.minLength }.maxOrNull(),
minComplexity = mapNotNull { it.minComplexity }.maxOrNull(),
requireUpper = mapNotNull { it.requireUpper }.any { it },
requireLower = mapNotNull { it.requireLower }.any { it },
requireNumbers = mapNotNull { it.requireNumbers }.any { it },
requireSpecial = mapNotNull { it.requireSpecial }.any { it },
enforceOnLogin = mapNotNull { it.enforceOnLogin }.any { it },
)

View File

@@ -959,4 +959,8 @@ Do you want to switch to this account?</string>
<string name="write_your_password_down">Write your password down</string>
<string name="keep_it_secret_keep_it_safe">Be careful to keep your written password somewhere secret and safe.</string>
<string name="prevent_account_lockout">Prevent account lockout</string>
<string name="generate_master_password">Generate master password</string>
<string name="generate_button_label">Generate</string>
<string name="write_this_password_down_and_keep_it_somewhere_safe">Write this password down and keep it somewhere safe.</string>
<string name="learn_about_other_ways_to_prevent_account_lockout">Learn about other ways to prevent account lockout</string>
</resources>

View File

@@ -0,0 +1,107 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class MasterPasswordGeneratorScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToPreventLockoutCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<MasterPasswordGeneratorEvent>()
private val mutableStateFlow = MutableStateFlow(
value = MasterPasswordGeneratorState(generatedPassword = "-"),
)
private val viewModel = mockk<MasterPasswordGeneratorViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow.asStateFlow()
}
@Before
fun setup() {
composeTestRule.setContent {
MasterPasswordGeneratorScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToPreventLockout = { onNavigateToPreventLockoutCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `Generated password field state should update with ViewModel state`() {
val updatedValue = "soup-r-stronk-pazzwerd"
mutableStateFlow.update { it.copy(generatedPassword = updatedValue) }
composeTestRule
.onNodeWithText(updatedValue)
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `NavigateBack event should invoke onNavigateBack lambda`() {
mutableEventFlow.tryEmit(MasterPasswordGeneratorEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToPreventLockout event should invoke onNavigateToPreventLockout lambda`() {
mutableEventFlow.tryEmit(MasterPasswordGeneratorEvent.NavigateToPreventLockout)
assertTrue(onNavigateToPreventLockoutCalled)
}
@Test
fun `Verify clicking the back navigation button sends correct action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction) }
}
@Test
fun `Verify clicking the save text button sends correct action`() {
composeTestRule
.onNodeWithText("Save")
.performClick()
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.SavePasswordClickAction) }
}
@Test
fun `Verify clicking the generate button sends correct action`() {
composeTestRule
.onNodeWithText("Generate")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(MasterPasswordGeneratorAction.GeneratePasswordClickAction)
}
}
@Test
fun `Verify clicking the learn to prevent lockout text sends correct action`() {
composeTestRule
.onNodeWithText("Learn about other ways to prevent account lockout")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(MasterPasswordGeneratorAction.PreventLockoutClickAction) }
}
}

View File

@@ -0,0 +1,138 @@
package com.x8bit.bitwarden.ui.auth.feature.masterpasswordgenerator
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MasterPasswordGeneratorViewModelTest : BaseViewModelTest() {
private val fakeGeneratorRepository = FakeGeneratorRepository()
private val mockPolicyManager = mockk<PolicyManager>(relaxed = true) {
every { getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) } returns emptyList()
}
@BeforeEach
fun setup() {
fakeGeneratorRepository.setMockGeneratePassphraseResult(
result = GeneratedPassphraseResult.Success(
generatedString = "generatedString",
),
)
}
@Test
@Suppress("MaxLineLength")
fun `With no saved state and failed generator result, initial password state is default value`() {
fakeGeneratorRepository.setMockGeneratePassphraseResult(
result = GeneratedPassphraseResult.InvalidRequest,
)
val viewModel = createViewModel()
assertEquals(
MasterPasswordGeneratorState(generatedPassword = "-"),
viewModel.stateFlow.value,
)
}
@Test
fun `With previous saved state, initial password state is saved value`() {
val savedPassword = "saved-pw"
val viewModel = createViewModel(
initialState = MasterPasswordGeneratorState(generatedPassword = savedPassword),
)
assertEquals(
MasterPasswordGeneratorState(generatedPassword = savedPassword),
viewModel.stateFlow.value,
)
}
@Test
fun `Verify passphrase request is created and attempts to check for policy constraints`() {
createViewModel()
verify { mockPolicyManager.getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) }
}
@Test
fun `State updates when generate action is sent and repository returns success result`() =
runTest {
val viewModel = createViewModel()
val newPassPhrase = "I am new"
val expectedResult = MasterPasswordGeneratorState(generatedPassword = newPassPhrase)
viewModel.stateFlow.test {
assertNotEquals(expectedResult, awaitItem())
fakeGeneratorRepository.setMockGeneratePassphraseResult(
GeneratedPassphraseResult.Success(generatedString = newPassPhrase),
)
viewModel.trySendAction(
MasterPasswordGeneratorAction.GeneratePasswordClickAction,
)
assertEquals(expectedResult, awaitItem())
}
}
@Test
fun `ShowSnackbar event is sent when the generate passphrase result is a failure`() = runTest {
val viewModel = createViewModel()
fakeGeneratorRepository.setMockGeneratePassphraseResult(
GeneratedPassphraseResult.InvalidRequest,
)
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGeneratorAction.GeneratePasswordClickAction)
assertEquals(
MasterPasswordGeneratorEvent.ShowSnackbar(R.string.an_error_has_occurred.asText()),
awaitItem(),
)
}
}
@Test
fun `NavigateBack event is sent when BackClickAction is handled`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGeneratorAction.BackClickAction)
assertEquals(MasterPasswordGeneratorEvent.NavigateBack, awaitItem())
}
}
@Test
fun `NavigateToPreventLockout event is sent when PreventLockoutClickAction is handled`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MasterPasswordGeneratorAction.PreventLockoutClickAction)
assertEquals(MasterPasswordGeneratorEvent.NavigateToPreventLockout, awaitItem())
}
}
// region helpers
private fun createViewModel(
initialState: MasterPasswordGeneratorState? = null,
): MasterPasswordGeneratorViewModel = MasterPasswordGeneratorViewModel(
savedStateHandle = SavedStateHandle().apply { this["state"] = initialState },
generatorRepository = fakeGeneratorRepository,
policyManager = mockPolicyManager,
)
// endregion helpers
}

View File

@@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.tools.feature.generator.util
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class PolicyInformationMasterPasswordExtensionsTest {
@Test
fun `toStrictestPolicy enforces the strictest MasterPassword policy`() {
assertEquals(
PolicyInformation.MasterPassword(
minLength = 24,
minComplexity = 24,
requireUpper = true,
requireLower = true,
requireNumbers = true,
requireSpecial = true,
enforceOnLogin = true,
),
listOf(
POLICY_1,
POLICY_2,
POLICY_3,
)
.toStrictestPolicy(),
)
}
}
private val POLICY_1 = PolicyInformation.MasterPassword(
minLength = 6,
minComplexity = 24,
requireUpper = false,
requireLower = true,
requireNumbers = false,
requireSpecial = false,
enforceOnLogin = true,
)
private val POLICY_2 = PolicyInformation.MasterPassword(
minLength = 24,
minComplexity = 12,
requireUpper = true,
requireLower = false,
requireNumbers = false,
requireSpecial = true,
enforceOnLogin = false,
)
private val POLICY_3 = PolicyInformation.MasterPassword(
minLength = 12,
minComplexity = 24,
requireUpper = false,
requireLower = false,
requireNumbers = true,
requireSpecial = false,
enforceOnLogin = false,
)