mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1921: Add JIT Provisioning (#1133)
This commit is contained in:
parent
509ef72546
commit
5b1545f53b
22 changed files with 1574 additions and 99 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = "",
|
||||
)
|
|
@ -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 = "",
|
||||
)
|
|
@ -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 {
|
||||
|
|
|
@ -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`() {
|
||||
|
|
Loading…
Add table
Reference in a new issue