BIT-1921: Add JIT Provisioning (#1133)

This commit is contained in:
Caleb Derosier 2024-03-13 12:14:28 -06:00 committed by Álison Fernandes
parent 509ef72546
commit 5b1545f53b
22 changed files with 1574 additions and 99 deletions

View file

@ -44,10 +44,10 @@ sealed class GetTokenResponseJson {
val expiresInSeconds: Int,
@SerialName("Key")
val key: String,
val key: String?,
@SerialName("PrivateKey")
val privateKey: String,
val privateKey: String?,
@SerialName("Kdf")
val kdfType: KdfTypeJson,

View file

@ -68,6 +68,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val yubiKeyResultFlow: Flow<YubiKeyResult>
/**
* The organization identifier currently associated with this user.
*/
var organizationIdentifier: String?
/**
* The two-factor response data necessary for login and also to populate the
* Two-Factor Login screen.
@ -153,12 +158,14 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
/**
* Attempt to login using a SSO flow. Updated access token will be reflected in [authStateFlow].
*/
@Suppress("LongParameterList")
suspend fun login(
email: String,
ssoCode: String,
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult
/**
@ -210,11 +217,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
): ResetPasswordResult
/**
* Sets the user's password to [password] for the user within the given [organizationId] with
* an optional [passwordHint].
* Sets the user's password to [password] for the user within the given [organizationIdentifier]
* with an optional [passwordHint].
*/
suspend fun setPassword(
organizationId: String,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult

View file

@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
@ -157,6 +158,8 @@ class AuthRepositoryImpl(
private val ioScope = CoroutineScope(dispatcherManager.io)
override var organizationIdentifier: String? = null
override var twoFactorResponse: TwoFactorRequired? = null
override val activeUserId: String? get() = authDiskSource.userState?.activeUserId
@ -400,6 +403,7 @@ class AuthRepositoryImpl(
ssoCodeVerifier: String,
ssoRedirectUri: String,
captchaToken: String?,
organizationIdentifier: String,
): LoginResult = loginCommon(
email = email,
authModel = IdentityTokenAuthModel.SingleSignOn(
@ -408,19 +412,21 @@ class AuthRepositoryImpl(
ssoRedirectUri = ssoRedirectUri,
),
captchaToken = captchaToken,
orgIdentifier = organizationIdentifier,
)
/**
* A helper function to extract the common logic of logging in through
* any of the available methods.
*/
@Suppress("LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun loginCommon(
email: String,
password: String? = null,
authModel: IdentityTokenAuthModel,
twoFactorData: TwoFactorDataModel? = null,
deviceData: DeviceDataModel? = null,
orgIdentifier: String? = null,
captchaToken: String?,
): LoginResult = identityService
.getToken(
@ -474,6 +480,11 @@ class AuthRepositoryImpl(
)
}
// Set the current organization identifier for use in JIT provisioning.
if (loginResponse.userDecryptionOptions?.hasMasterPassword == false) {
organizationIdentifier = orgIdentifier
}
// Remove any cached data after successfully logging in.
identityTokenAuthModel = null
twoFactorResponse = null
@ -482,17 +493,19 @@ class AuthRepositoryImpl(
// Attempt to unlock the vault with password if possible.
password?.let {
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = it,
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
if (loginResponse.privateKey != null && loginResponse.key != null) {
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = it,
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
}
// Save the master password hash.
authSdkSource
@ -515,34 +528,37 @@ class AuthRepositoryImpl(
}
// Attempt to unlock the vault with auth request if possible.
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(
protectedUserKey = model.asymmetricalKey,
),
),
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since it
// is not formatted in in a manner that we can use. We will store it
// properly the next time the user enters their master password and
// it is validated.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(
protectedUserKey = model.asymmetricalKey,
),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since
// it is not formatted in in a manner that we can use. We will store
// it properly the next time the user enters their master password
// and it is validated.
}
}
authDiskSource.storeAccountTokens(
@ -805,8 +821,9 @@ class AuthRepositoryImpl(
)
}
@Suppress("LongMethod")
override suspend fun setPassword(
organizationId: String,
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
@ -832,28 +849,40 @@ class AuthRepositoryImpl(
kdf = activeAccount.profile.toSdkParams(),
)
.flatMap { keyResponse ->
accountsService.setPassword(
body = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationId,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = keyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = keyResponse.keys.public,
encryptedPrivateKey = keyResponse.keys.private,
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = activeAccount.profile.kdfIterations,
kdfMemory = activeAccount.profile.kdfMemory,
kdfParallelism = activeAccount.profile.kdfParallelism,
kdfType = activeAccount.profile.kdfType,
key = keyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = keyResponse.keys.public,
encryptedPrivateKey = keyResponse.keys.private,
),
),
),
)
)
.onSuccess {
authDiskSource.storePrivateKey(
userId = activeAccount.profile.userId,
privateKey = keyResponse.keys.private,
)
authDiskSource.storeUserKey(
userId = activeAccount.profile.userId,
userKey = keyResponse.encryptedUserKey,
)
}
}
.onSuccess {
authDiskSource.storeMasterPasswordHash(
userId = activeAccount.profile.userId,
passwordHash = passwordHash,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword()
}
.fold(
onFailure = { SetPasswordResult.Error },

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
@ -40,6 +41,35 @@ fun UserStateJson.toUpdatedUserStateJson(
)
}
/**
* Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
val account = this.accounts[activeUserId] ?: return this
val profile = account.profile
val updatedProfile = profile
.copy(
userDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = true)
?: UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(activeUserId, updatedAccount)
},
)
}
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/

View file

@ -20,6 +20,8 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestin
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
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.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
@ -49,6 +51,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },
onNavigateToTwoFactorLogin = { emailAddress ->
navController.navigateToTwoFactorLogin(
emailAddress = emailAddress,
@ -56,6 +59,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
)
setPasswordDestination()
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress ->

View file

@ -38,6 +38,7 @@ fun NavController.navigateToEnterpriseSignOn(
*/
fun NavGraphBuilder.enterpriseSignOnDestination(
onNavigateBack: () -> Unit,
onNavigateToSetPassword: () -> Unit,
onNavigateToTwoFactorLogin: (emailAddress: String) -> Unit,
) {
composableWithSlideTransitions(
@ -48,6 +49,7 @@ fun NavGraphBuilder.enterpriseSignOnDestination(
) {
EnterpriseSignOnScreen(
onNavigateBack = onNavigateBack,
onNavigateToSetPassword = onNavigateToSetPassword,
onNavigateToTwoFactorLogin = onNavigateToTwoFactorLogin,
)
}

View file

@ -50,6 +50,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
@Composable
fun EnterpriseSignOnScreen(
onNavigateBack: () -> Unit,
onNavigateToSetPassword: () -> Unit,
onNavigateToTwoFactorLogin: (String) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: EnterpriseSignOnViewModel = hiltViewModel(),
@ -67,6 +68,10 @@ fun EnterpriseSignOnScreen(
intentManager.startCustomTabsActivity(event.uri)
}
is EnterpriseSignOnEvent.NavigateToSetPassword -> {
onNavigateToSetPassword()
}
is EnterpriseSignOnEvent.NavigateToTwoFactorLogin -> {
onNavigateToTwoFactorLogin(event.emailAddress)
}

View file

@ -343,7 +343,8 @@ class EnterpriseSignOnViewModel @Inject constructor(
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = SSO_URI,
captchaToken = mutableStateFlow.value.captchaToken,
captchaToken = state.captchaToken,
organizationIdentifier = state.orgIdentifierInput,
)
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
@ -472,6 +473,11 @@ sealed class EnterpriseSignOnEvent {
*/
data class NavigateToCaptcha(val uri: Uri) : EnterpriseSignOnEvent()
/**
* Navigates to the set master password screen.
*/
data object NavigateToSetPassword : EnterpriseSignOnEvent()
/**
* Navigates to the two-factor login screen.
*/

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val SET_PASSWORD_ROUTE: String = "set_password"
/**
* Add the Set Password screen to the nav graph.
*/
fun NavGraphBuilder.setPasswordDestination() {
composable(
route = SET_PASSWORD_ROUTE,
) {
SetPasswordScreen()
}
}
/**
* Navigate to the Set Password screen.
*/
fun NavController.navigateToSetPassword(
navOptions: NavOptions? = null,
) {
this.navigate(SET_PASSWORD_ROUTE, navOptions)
}

View file

@ -0,0 +1,206 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.compose.foundation.layout.Column
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.imePadding
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.Text
import androidx.compose.material3.TopAppBarDefaults
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.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
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.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
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.BitwardenPolicyWarningText
/**
* The top level composable for the Set Master Password screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetPasswordScreen(
viewModel: SetPasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
SetPasswordDialogs(
dialogState = state.dialogState,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.DialogDismiss) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenMediumTopAppBar(
title = stringResource(id = R.string.set_master_password),
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.cancel),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.CancelClick) }
},
modifier = Modifier.semantics { testTag = "CancelButton" },
)
BitwardenTextButton(
label = stringResource(id = R.string.submit),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.SubmitClick) }
},
modifier = Modifier.semantics { testTag = "SubmitButton" },
)
},
)
},
) { innerPadding ->
SetPasswordScreenContent(
state = state,
onPasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(it)) }
},
onRetypePasswordInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(it)) }
},
onPasswordHintInputChanged = remember(viewModel) {
{ viewModel.trySendAction(SetPasswordAction.PasswordHintInputChanged(it)) }
},
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize(),
)
}
}
@Composable
@Suppress("LongMethod")
private fun SetPasswordScreenContent(
state: SetPasswordState,
onPasswordInputChanged: (String) -> Unit,
onRetypePasswordInputChanged: (String) -> Unit,
onPasswordHintInputChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(
id = R.string.your_organization_requires_you_to_set_a_master_password,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPolicyWarningText(
text = stringResource(id = R.string.reset_password_auto_enroll_invite_warning),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = onPasswordInputChanged,
hint = stringResource(id = R.string.master_password_description),
modifier = Modifier
.semantics { testTag = "NewPasswordField" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.retypePasswordInput,
onValueChange = onRetypePasswordInputChanged,
modifier = Modifier
.semantics { testTag = "RetypePasswordField" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = onPasswordHintInputChanged,
hint = stringResource(id = R.string.master_password_hint_description),
modifier = Modifier
.semantics { testTag = "MasterPasswordHintLabel" }
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun SetPasswordDialogs(
dialogState: SetPasswordState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is SetPasswordState.DialogState.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissRequest,
)
}
is SetPasswordState.DialogState.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(
text = dialogState.message,
),
)
}
null -> Unit
}
}

View file

@ -0,0 +1,385 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import android.os.Parcelable
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.SetPasswordResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels
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.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/**
* Manages application state for the Set Password screen.
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class SetPasswordViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SetPasswordState, SetPasswordEvent, SetPasswordAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val organizationIdentifier = authRepository.organizationIdentifier
if (organizationIdentifier.isNullOrBlank()) authRepository.logout()
SetPasswordState(
dialogState = null,
organizationIdentifier = organizationIdentifier.orEmpty(),
passwordInput = "",
passwordHintInput = "",
policies = authRepository.passwordPolicies.toDisplayLabels(),
retypePasswordInput = "",
)
},
) {
override fun handleAction(action: SetPasswordAction) {
when (action) {
SetPasswordAction.CancelClick -> handleCancelClick()
SetPasswordAction.SubmitClick -> handleSubmitClicked()
SetPasswordAction.DialogDismiss -> handleDialogDismiss()
is SetPasswordAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is SetPasswordAction.RetypePasswordInputChanged -> {
handleRetypePasswordInputChanged(action)
}
is SetPasswordAction.PasswordHintInputChanged -> {
handlePasswordHintInputChanged(action)
}
is SetPasswordAction.Internal.ReceiveUnlockVaultResult -> {
handleReceiveUnlockVaultResult(action)
}
is SetPasswordAction.Internal.ReceiveSetPasswordResult -> {
handleReceiveSetPasswordResult(action)
}
is SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult -> {
handleReceiveValidatePasswordAgainstPoliciesResult(action)
}
}
}
/**
* Dismiss the view if the user cancels the set master password functionality.
*/
private fun handleCancelClick() {
authRepository.logout()
}
/**
* Validate the user's current password when they submit.
*/
private fun handleSubmitClicked() {
// Display an error dialog if the new password field is blank.
if (state.passwordInput.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.master_password.asText()),
),
)
}
return
}
// Validate password against policies if there are any.
if (state.policies.isNotEmpty()) {
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult(
authRepository.validatePasswordAgainstPolicies(state.passwordInput),
),
)
}
} else if (state.passwordInput.length < MIN_PASSWORD_LENGTH) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
)
}
} else if (state.passwordInput == state.retypePasswordInput) {
setPassword()
} else {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
)
}
}
}
/**
* Dismiss the dialog state.
*/
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(
dialogState = null,
)
}
}
/**
* Update the state with the new master password input.
*/
private fun handlePasswordInputChanged(action: SetPasswordAction.PasswordInputChanged) {
mutableStateFlow.update {
it.copy(
passwordInput = action.input,
)
}
}
/**
* Update the state with the re-typed master password input.
*/
private fun handleRetypePasswordInputChanged(
action: SetPasswordAction.RetypePasswordInputChanged,
) {
mutableStateFlow.update {
it.copy(
retypePasswordInput = action.input,
)
}
}
/**
* Update the state with the password hint input.
*/
private fun handlePasswordHintInputChanged(
action: SetPasswordAction.PasswordHintInputChanged,
) {
mutableStateFlow.update {
it.copy(
passwordHintInput = action.input,
)
}
}
private fun handleReceiveUnlockVaultResult(
action: SetPasswordAction.Internal.ReceiveUnlockVaultResult,
) {
when (action.result) {
is VaultUnlockResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
}
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.InvalidStateError,
is VaultUnlockResult.GenericError,
-> {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
/**
* Show an alert if the set password attempt failed, otherwise attempt to unlock the vault.
*/
private fun handleReceiveSetPasswordResult(
action: SetPasswordAction.Internal.ReceiveSetPasswordResult,
) {
when (action.result) {
SetPasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
SetPasswordResult.Success -> {
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveUnlockVaultResult(
result = vaultRepository.unlockVaultWithMasterPassword(
masterPassword = state.passwordInput,
),
),
)
}
}
}
}
/**
* Display an alert if the password doesn't meet the policy requirements, then check that
* the new password matches the retyped password and that the current password is valid.
*/
private fun handleReceiveValidatePasswordAgainstPoliciesResult(
action: SetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult,
) {
// Display an error alert if the new password doesn't meet the policy requirements.
if (!action.meetsRequirements) {
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.master_password_policy_validation_title.asText(),
message = R.string.master_password_policy_validation_message.asText(),
),
)
}
}
}
/**
* A helper function to launch the set password request.
*/
private fun setPassword() {
// Show the loading dialog.
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
)
}
viewModelScope.launch {
sendAction(
SetPasswordAction.Internal.ReceiveSetPasswordResult(
result = authRepository.setPassword(
organizationIdentifier = state.organizationIdentifier,
password = state.passwordInput,
passwordHint = state.passwordHintInput,
),
),
)
}
}
}
/**
* Models state of the Set Password screen.
*/
@Parcelize
data class SetPasswordState(
val dialogState: DialogState?,
val organizationIdentifier: String,
val passwordHintInput: String,
val passwordInput: String,
val policies: List<Text>,
val retypePasswordInput: String,
) : Parcelable {
/**
* Represents the current state of any dialogs on the screen.
*/
sealed class DialogState : Parcelable {
/**
* Represents an error dialog with the given [message] and optional [title]. If no title
* is specified a default will be provided.
*/
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@Parcelize
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Models events for the Set Password screen.
*/
sealed class SetPasswordEvent
/**
* Models actions for the Set Password screen.
*/
sealed class SetPasswordAction {
/**
* Indicates that the user has confirmed logging out.
*/
data object CancelClick : SetPasswordAction()
/**
* Indicates that the user has clicked the submit button.
*/
data object SubmitClick : SetPasswordAction()
/**
* Indicates that the dialog has been dismissed.
*/
data object DialogDismiss : SetPasswordAction()
/**
* Indicates that the master password input has changed.
*/
data class PasswordInputChanged(val input: String) : SetPasswordAction()
/**
* Indicates that the re-type master password input has changed.
*/
data class RetypePasswordInputChanged(val input: String) : SetPasswordAction()
/**
* Indicates that the password hint input has changed.
*/
data class PasswordHintInputChanged(val input: String) : SetPasswordAction()
/**
* Models actions that the [SetPasswordViewModel] might send itself.
*/
sealed class Internal : SetPasswordAction() {
/**
* Indicates that a login result has been received.
*/
data class ReceiveUnlockVaultResult(
val result: VaultUnlockResult,
) : Internal()
/**
* Indicates that a set password result has been received.
*/
data class ReceiveSetPasswordResult(
val result: SetPasswordResult,
) : Internal()
/**
* Indicates that a validate password against policies result has been received.
*/
data class ReceiveValidatePasswordAgainstPoliciesResult(
val meetsRequirements: Boolean,
) : Internal()
}
}

View file

@ -19,6 +19,8 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.setpassword.SET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
@ -87,6 +89,7 @@ fun RootNavScreen(
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
is RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
is RootNavState.VaultUnlocked,
@ -126,6 +129,7 @@ fun RootNavScreen(
when (val currentState = state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
is RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(

View file

@ -60,6 +60,8 @@ class RootNavViewModel @Inject constructor(
val userState = action.userState
val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when {
userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
userState == null ||
@ -117,6 +119,12 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object ResetPassword : RootNavState()
/**
* App should show set password graph.
*/
@Parcelize
data object SetPassword : RootNavState()
/**
* App should show splash nav graph.
*/

View file

@ -123,7 +123,7 @@ class AccountsServiceTest : BaseServiceTest() {
fun `register success json should be Success`() = runTest {
val json = """
{
"captchaBypassToken": "mock_token"
"captchaBypassToken": "mock_token"
}
"""
val expectedResponse = RegisterResponseJson.Success(

View file

@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordReque
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@ -396,8 +397,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -462,8 +463,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -811,8 +812,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -854,8 +855,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -868,6 +869,82 @@ class AuthRepositoryTest {
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
}
@Test
@Suppress("MaxLineLength")
fun `login get token succeeds with null keys and hasMasterPassword false should not call unlockVault`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy(
key = null,
privateKey = null,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = false,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
),
)
coEvery {
accountsService.preLogin(email = EMAIL)
} returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
successResponse.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
val result = repository.login(
email = EMAIL,
password = PASSWORD,
captchaToken = null,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { accountsService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertMasterPasswordHash(
userId = USER_ID_1,
passwordHash = PASSWORD_HASH,
)
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.syncIfNecessary()
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
coVerify(exactly = 0) {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = any(),
privateKey = any(),
organizationKeys = null,
masterPassword = PASSWORD,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `login get token succeeds when there is an existing user should switch to the new logged in user and lock the old user's vault`() =
@ -897,8 +974,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -938,8 +1015,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -1083,8 +1160,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -1139,8 +1216,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -1179,8 +1256,8 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
userKey = successResponse.key!!,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
masterPassword = PASSWORD,
)
@ -1319,12 +1396,12 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.MasterKey(
authRequestKey = successResponse.key,
authRequestKey = successResponse.key!!,
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
),
),
@ -1365,12 +1442,12 @@ class AuthRepositoryTest {
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
privateKey = successResponse.privateKey,
privateKey = successResponse.privateKey!!,
organizationKeys = null,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.MasterKey(
authRequestKey = successResponse.key,
authRequestKey = successResponse.key!!,
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
),
),
@ -1539,12 +1616,12 @@ class AuthRepositoryTest {
userId = SINGLE_USER_STATE_1.activeUserId,
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(),
privateKey = successResponse.privateKey,
privateKey = successResponse.privateKey!!,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = DEVICE_REQUEST_PRIVATE_KEY,
method = AuthRequestMethod.MasterKey(
protectedMasterKey = DEVICE_ASYMMETRICAL_KEY,
authRequestKey = successResponse.key,
authRequestKey = successResponse.key!!,
),
),
organizationKeys = null,
@ -1584,6 +1661,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Error(errorMessage = null), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
@ -1628,6 +1706,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
@ -1675,6 +1754,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
@ -1742,6 +1822,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Success, result)
@ -1795,6 +1876,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
@ -1839,6 +1921,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
@ -1889,6 +1972,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.TwoFactorRequired, firstResult)
coVerify {
@ -1977,6 +2061,7 @@ class AuthRepositoryTest {
ssoCodeVerifier = SSO_CODE_VERIFIER,
ssoRedirectUri = SSO_REDIRECT_URI,
captchaToken = null,
organizationIdentifier = ORGANIZATION_IDENTIFIER,
)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
@ -2463,13 +2548,15 @@ class AuthRepositoryTest {
fakeAuthDiskSource.userState = null
val result = repository.setPassword(
organizationId = "organizationId",
organizationIdentifier = "organizationId",
password = "password",
passwordHint = "passwordHint",
)
assertEquals(SetPasswordResult.Error, result)
fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
}
@Test
@ -2486,13 +2573,15 @@ class AuthRepositoryTest {
} returns Throwable("Fail").asFailure()
val result = repository.setPassword(
organizationId = "organizationId",
organizationIdentifier = "organizationId",
password = password,
passwordHint = "passwordHint",
)
assertEquals(SetPasswordResult.Error, result)
fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
}
@Test
@ -2518,13 +2607,15 @@ class AuthRepositoryTest {
} returns Throwable("Fail").asFailure()
val result = repository.setPassword(
organizationId = "organizationId",
organizationIdentifier = "organizationId",
password = password,
passwordHint = "passwordHint",
)
assertEquals(SetPasswordResult.Error, result)
fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
}
@Test
@ -2532,7 +2623,7 @@ class AuthRepositoryTest {
val password = "password"
val passwordHash = "passwordHash"
val passwordHint = "passwordHint"
val organizationId = "organizationIdentifier"
val organizationId = ORGANIZATION_IDENTIFIER
val encryptedUserKey = "encryptedUserKey"
val privateRsaKey = "privateRsaKey"
val publicRsaKey = "publicRsaKey"
@ -2574,13 +2665,15 @@ class AuthRepositoryTest {
} returns Throwable("Fail").asFailure()
val result = repository.setPassword(
organizationId = organizationId,
organizationIdentifier = organizationId,
password = password,
passwordHint = passwordHint,
)
assertEquals(SetPasswordResult.Error, result)
fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
}
@Test
@ -2588,7 +2681,7 @@ class AuthRepositoryTest {
val password = "password"
val passwordHash = "passwordHash"
val passwordHint = "passwordHint"
val organizationId = "organizationIdentifier"
val organizationId = ORGANIZATION_IDENTIFIER
val encryptedUserKey = "encryptedUserKey"
val privateRsaKey = "privateRsaKey"
val publicRsaKey = "publicRsaKey"
@ -2630,13 +2723,16 @@ class AuthRepositoryTest {
} returns Unit.asSuccess()
val result = repository.setPassword(
organizationId = organizationId,
organizationIdentifier = organizationId,
password = password,
passwordHint = passwordHint,
)
assertEquals(SetPasswordResult.Success, result)
fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = passwordHash)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateRsaKey)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey)
fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1_WITH_PASS)
}
@Test
@ -3373,6 +3469,7 @@ class AuthRepositoryTest {
private const val PRIVATE_KEY = "privateKey"
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
private const val ORGANIZATION_IDENTIFIER = "organizationIdentifier"
private val ORGANIZATIONS = listOf(createMockOrganization(number = 0))
private val TWO_FACTOR_AUTH_METHODS_DATA = mapOf(
TwoFactorAuthMethod.EMAIL to JsonObject(
@ -3460,6 +3557,20 @@ class AuthRepositoryTest {
USER_ID_1 to ACCOUNT_1,
),
)
private val SINGLE_USER_STATE_1_WITH_PASS = UserStateJson(
activeUserId = USER_ID_1,
accounts = mapOf(
USER_ID_1 to ACCOUNT_1.copy(
profile = ACCOUNT_1.profile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
),
),
),
),
)
private val SINGLE_USER_STATE_2 = UserStateJson(
activeUserId = USER_ID_2,
accounts = mapOf(

View file

@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
@ -94,6 +96,114 @@ class UserStateJsonExtensionsTest {
)
}
@Test
fun `toUserStateJsonWithPassword should update correct account to set needsMasterPassword`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
),
),
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(),
)
}
@Test
fun `toUserStateJsonWithPassword should preserve values of userDecryptionOptions`() {
val keyConnectorOptionsJson = KeyConnectorUserDecryptionOptionsJson("key")
val trustedDeviceOptionsJson = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
)
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
),
),
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(),
)
}
@Test
fun `toUserState should return the correct UserState for an unlocked vault`() {
assertEquals(

View file

@ -30,6 +30,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
class EnterpriseSignOnScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToSetPasswordCalled = false
private var twoFactorLoginEmail: String? = null
private val mutableEventFlow = bufferedMutableSharedFlow<EnterpriseSignOnEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -47,6 +48,7 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() {
composeTestRule.setContent {
EnterpriseSignOnScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToSetPassword = { onNavigateToSetPasswordCalled = true },
onNavigateToTwoFactorLogin = { twoFactorLoginEmail = it },
viewModel = viewModel,
intentManager = intentManager,
@ -114,6 +116,12 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() {
}
}
@Test
fun `NavigateToSetPassword should call onNavigateToSetPassword`() {
mutableEventFlow.tryEmit(EnterpriseSignOnEvent.NavigateToSetPassword)
assertTrue(onNavigateToSetPasswordCalled)
}
@Test
fun `NavigateToTwoFactorLogin should call onNavigateToTwoFactorLogin`() {
val email = "test@example.com"

View file

@ -306,8 +306,9 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
@Test
fun `ssoCallbackResultFlow Success with same state with login Error should show loading dialog then show an error`() =
runTest {
val orgIdentifier = "Bitwarden"
coEvery {
authRepository.login(any(), any(), any(), any(), any())
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.Error(null)
val viewModel = createViewModel(
@ -321,6 +322,17 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
awaitItem(),
)
viewModel.trySendAction(
EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier),
)
assertEquals(
DEFAULT_STATE.copy(
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult)
assertEquals(
@ -328,6 +340,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
dialogState = EnterpriseSignOnState.DialogState.Loading(
R.string.logging_in.asText(),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
@ -337,6 +350,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
dialogState = EnterpriseSignOnState.DialogState.Error(
message = R.string.login_sso_error.asText(),
),
orgIdentifierInput = orgIdentifier,
),
awaitItem(),
)
@ -349,6 +363,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = orgIdentifier,
)
}
}
@ -358,7 +373,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
fun `ssoCallbackResultFlow Success with same state with login Success should show loading dialog, hide it, and save org identifier`() =
runTest {
coEvery {
authRepository.login(any(), any(), any(), any(), any())
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.Success
coEvery {
@ -402,6 +417,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = "Bitwarden",
)
}
coVerify(exactly = 1) {
@ -414,7 +430,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
fun `ssoCallbackResultFlow Success with same state with login CaptchaRequired should show loading dialog, hide it, and send NavigateToCaptcha event`() =
runTest {
coEvery {
authRepository.login(any(), any(), any(), any(), any())
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.CaptchaRequired("captcha")
val uri: Uri = mockk()
@ -464,6 +480,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = "Bitwarden",
)
}
}
@ -473,7 +490,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
fun `ssoCallbackResultFlow Success with same state with login TwoFactorRequired should show loading dialog, hide it, and send NavigateToTwoFactorLogin event`() =
runTest {
coEvery {
authRepository.login(any(), any(), any(), any(), any())
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.TwoFactorRequired
val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden")
@ -518,6 +535,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
ssoCodeVerifier = "def",
ssoRedirectUri = "bitwarden://sso-callback",
captchaToken = null,
organizationIdentifier = "Bitwarden",
)
}
}
@ -545,7 +563,7 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
@Test
fun `captchaTokenResultFlow Success should update the state and attempt to login`() = runTest {
coEvery {
authRepository.login(any(), any(), any(), any(), any())
authRepository.login(any(), any(), any(), any(), any(), any())
} returns LoginResult.Success
coEvery {
@ -748,5 +766,6 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
codeVerifier = "def",
)
private const val DEFAULT_EMAIL = "test@gmail.com"
private const val DEFAULT_ORG_ID = "orgId"
}
}

View file

@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class SetPasswordScreenTest : BaseComposeTest() {
private val mutableEventFlow = bufferedMutableSharedFlow<SetPasswordEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<SetPasswordViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
SetPasswordScreen(
viewModel = viewModel,
)
}
}
@Test
fun `basicDialog should update according to state`() {
composeTestRule
.onNodeWithText("Error message")
.assertDoesNotExist()
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = null,
message = "Error message".asText(),
),
)
}
composeTestRule
.onNodeWithText("Error message")
.assert(hasAnyAncestor(isDialog()))
.isDisplayed()
}
@Test
fun `loadingDialog should update according to state`() {
composeTestRule.onNodeWithText("Loading...").assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = SetPasswordState.DialogState.Loading(
message = "Loading...".asText(),
),
)
}
composeTestRule.onNodeWithText("Loading...").isDisplayed()
}
@Test
fun `cancel button click should emit CancelClick`() {
composeTestRule.onNodeWithText("Cancel").performClick()
verify {
viewModel.trySendAction(SetPasswordAction.CancelClick)
}
}
@Test
fun `submit button click should emit SubmitClick`() {
composeTestRule.onNodeWithText("Submit").performClick()
verify {
viewModel.trySendAction(SetPasswordAction.SubmitClick)
}
}
@Test
fun `password input change should send PasswordInputChange action`() {
val input = "Test123"
composeTestRule.onNodeWithText("Master password").performTextInput(input)
verify {
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged("Test123"))
}
}
@Test
fun `retype password input change should send RetypePasswordInputChanged action`() {
val input = "Test123"
composeTestRule.onNodeWithText("Re-type master password").performTextInput(input)
verify {
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged("Test123"))
}
}
@Test
fun `password hint input change should send PasswordHintInputChanged action`() {
val input = "Test123"
composeTestRule.onNodeWithText("Master password hint (optional)").performTextInput(input)
verify {
viewModel.trySendAction(SetPasswordAction.PasswordHintInputChanged("Test123"))
}
}
}
private val DEFAULT_STATE = SetPasswordState(
dialogState = null,
organizationIdentifier = "SSO",
passwordHintInput = "",
passwordInput = "",
policies = emptyList(),
retypePasswordInput = "",
)

View file

@ -0,0 +1,345 @@
package com.x8bit.bitwarden.ui.auth.feature.setpassword
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.SetPasswordResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SetPasswordViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk {
every { passwordPolicies } returns emptyList()
every { organizationIdentifier } returns ORGANIZATION_IDENTIFIER
}
private val vaultRepository: VaultRepository = mockk()
@Test
fun `null organizationIdentifier logs user out`() = runTest {
every { authRepository.logout() } just runs
every { authRepository.organizationIdentifier } returns null
createViewModel()
verify { authRepository.logout() }
}
@Test
fun `CancelClick calls logout`() = runTest {
every { authRepository.logout() } just runs
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.CancelClick)
verify { authRepository.logout() }
}
@Test
fun `SubmitClicked with blank password shows error alert`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.master_password.asText()),
),
),
viewModel.stateFlow.value,
)
// Dismiss the alert.
viewModel.trySendAction(SetPasswordAction.DialogDismiss)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
}
@Test
fun `SubmitClicked with invalid password shows error alert for short password`() = runTest {
val password = "TestPass"
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
),
passwordInput = password,
retypePasswordInput = password,
),
viewModel.stateFlow.value,
)
}
@Test
fun `SubmitClicked with non-matching retyped password shows error alert`() = runTest {
val password = "TestPassword123"
coEvery {
authRepository.validatePasswordAgainstPolicies(password)
} returns true
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
passwordInput = password,
),
viewModel.stateFlow.value,
)
}
@Test
fun `SubmitClicked with invalid password shows error alert for weak password reason`() =
runTest {
val password = "Test123"
coEvery {
authRepository.validatePasswordAgainstPolicies(password)
} returns false
val viewModel = createViewModel(
state = SetPasswordState(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
policies = listOf(
R.string.policy_in_effect_uppercase.asText(),
),
dialogState = null,
passwordInput = "",
retypePasswordInput = "",
passwordHintInput = "",
),
)
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.master_password_policy_validation_title.asText(),
message = R.string.master_password_policy_validation_message.asText(),
),
passwordInput = password,
retypePasswordInput = password,
policies = listOf(
R.string.policy_in_effect_uppercase.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify {
authRepository.validatePasswordAgainstPolicies(password)
}
}
@Test
fun `SubmitClicked with all valid inputs and unlock vault success sets password`() = runTest {
val password = "TestPassword123"
coEvery {
authRepository.setPassword(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
password = password,
passwordHint = "",
)
} returns SetPasswordResult.Success
coEvery {
vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.Success
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(password))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
dialogState = null,
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = null,
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
}
coVerify {
authRepository.setPassword(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
password = password,
passwordHint = "",
)
vaultRepository.unlockVaultWithMasterPassword(password)
}
}
@Test
fun `SubmitClicked with all valid inputs and unlock vault failure shows error`() = runTest {
val password = "TestPassword123"
coEvery {
authRepository.setPassword(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
password = password,
passwordHint = "",
)
} returns SetPasswordResult.Success
coEvery {
vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.InvalidStateError
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged(password))
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged(password))
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
dialogState = null,
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
viewModel.trySendAction(SetPasswordAction.SubmitClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Loading(
message = R.string.updating_password.asText(),
),
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
passwordInput = password,
retypePasswordInput = password,
),
awaitItem(),
)
}
coVerify {
authRepository.setPassword(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
password = password,
passwordHint = "",
)
vaultRepository.unlockVaultWithMasterPassword(password)
}
}
@Test
fun `PasswordInputChanged should update the password input in the state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordInputChanged("TestPassword123"))
assertEquals(
DEFAULT_STATE.copy(
passwordInput = "TestPassword123",
),
viewModel.stateFlow.value,
)
}
@Test
fun `RetypePasswordInputChanged should update the retype password input in the state`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.RetypePasswordInputChanged("TestPassword123"))
assertEquals(
DEFAULT_STATE.copy(
retypePasswordInput = "TestPassword123",
),
viewModel.stateFlow.value,
)
}
@Test
fun `PasswordHintInputChanged should update the password hint input in the state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SetPasswordAction.PasswordHintInputChanged("TestPassword123"))
assertEquals(
DEFAULT_STATE.copy(
passwordHintInput = "TestPassword123",
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
state: SetPasswordState? = null,
): SetPasswordViewModel =
SetPasswordViewModel(
authRepository = authRepository,
vaultRepository = vaultRepository,
savedStateHandle = SavedStateHandle(mapOf("state" to state)),
)
}
private const val MIN_PASSWORD_LENGTH = 12
private const val ORGANIZATION_IDENTIFIER: String = "orgId"
private val DEFAULT_STATE = SetPasswordState(
organizationIdentifier = ORGANIZATION_IDENTIFIER,
policies = emptyList(),
dialogState = null,
passwordInput = "",
retypePasswordInput = "",
passwordHintInput = "",
)

View file

@ -91,6 +91,15 @@ class RootNavScreenTest : BaseComposeTest() {
)
}
// Make sure navigating to set password works as expected:
rootNavStateFlow.value = RootNavState.SetPassword
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "set_password",
navOptions = expectedNavOptions,
)
}
// Make sure navigating to vault unlocked works as expected:
rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId")
composeTestRule.runOnIdle {

View file

@ -86,6 +86,36 @@ class RootNavViewModelTest : BaseViewModelTest() {
assertEquals(RootNavState.ResetPassword, viewModel.stateFlow.value)
}
@Test
fun `when the active user needs a master password the nav state should be SetPassword`() {
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = true,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = true,
),
),
),
)
val viewModel = createViewModel()
assertEquals(
RootNavState.SetPassword,
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user but there are pending account additions the nav state should be Auth`() {