diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt index 5b612ed85..369782c41 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index d9f199778..0026f6acf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 391893027..88b71b4c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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 }, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index 6abb5defc..d932c811c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -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]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index bb993c5c1..e42fd41f0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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 -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt index f6cf381fc..006ba80a1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnNavigation.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt index aeeaf013f..d42f6d66c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index d81287b84..a1cfd331d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordNavigation.kt new file mode 100644 index 000000000..5b1a40481 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreen.kt new file mode 100644 index 000000000..49f25a417 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreen.kt @@ -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 + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModel.kt new file mode 100644 index 000000000..e11eca412 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModel.kt @@ -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() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 5918fae05..c2a853bdc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 42dd85274..97bbb606a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index 60864ddda..229852a1b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index f25dcba63..c4c5cd61e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index e13605b1a..dd3946f4b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt index 94991b13b..e1d590656 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -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" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index 76cb1ab15..8856af73a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -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" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreenTest.kt new file mode 100644 index 000000000..33cd0fcd5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordScreenTest.kt @@ -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 = "", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModelTest.kt new file mode 100644 index 000000000..ce327971e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/setpassword/SetPasswordViewModelTest.kt @@ -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 = "", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 2c169f19f..e68f825b7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 97e7fc206..d5c2dd876 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -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`() {