feat(auth): Add support for password validation type (main, secondary).

This commit is contained in:
Mateusz Armatys
2025-08-01 16:28:55 +02:00
committed by MargeBot
parent e7e5df5010
commit be82f85ca2
12 changed files with 132 additions and 18 deletions
@@ -60,6 +60,7 @@ import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.theme.ProtonTypography
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.presentation.report.PasswordPolicyReport
@Composable
@@ -215,6 +216,7 @@ private fun BackupPasswordChangeForm(
ProtonTextFieldError(errorText = errorMsg)
}
PasswordPolicyReport(
passwordValidationType = PasswordValidationType.Main,
password = backupPassword,
userId = userId,
onResult = { isPasswordValid = it != null },
@@ -69,6 +69,7 @@ import me.proton.core.compose.theme.ProtonDimens
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.compose.util.formatBold
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.presentation.report.PasswordPolicyReport
private val CompanyLogoSize = 56.dp
@@ -288,6 +289,7 @@ private fun BackupPasswordSetupForm(
ProtonTextFieldError(errorText = errorMsg)
}
PasswordPolicyReport(
passwordValidationType = PasswordValidationType.Main,
password = backupPassword,
userId = userId,
onResult = { isPasswordValid = it != null },
@@ -29,6 +29,7 @@ import me.proton.core.auth.presentation.databinding.FragmentSignupChoosePassword
import me.proton.core.auth.presentation.util.setTextWithAnnotatedLink
import me.proton.core.auth.presentation.viewmodel.signup.SignupViewModel
import me.proton.core.observability.domain.metrics.SignupScreenViewTotalV1
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.presentation.report.PasswordPolicyReport
import me.proton.core.presentation.utils.hideKeyboard
import me.proton.core.presentation.utils.launchOnScreenView
@@ -96,6 +97,7 @@ class ChoosePasswordFragment : SignupFragment(R.layout.fragment_signup_choose_pa
passwordPolicies.setContent {
PasswordPolicyReport(
passwordValidationType = PasswordValidationType.Main,
passwordFlow = passwordInput.textChanges().map { it.toString() },
userId = null,
onResult = { isPasswordValid = it != null }
@@ -34,6 +34,7 @@ import me.proton.core.passvalidator.data.entity.PasswordValidatorTokenImpl
import me.proton.core.passvalidator.data.validator.CommonPasswordValidator
import me.proton.core.passvalidator.data.validator.MinLengthPasswordValidator
import me.proton.core.passvalidator.data.validator.PasswordValidator
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.usecase.ValidatePassword
import me.proton.core.presentation.utils.InvalidPasswordProvider
import me.proton.core.util.kotlin.DispatcherProvider
@@ -80,9 +81,13 @@ public class ValidatePasswordImpl internal constructor(
)
override fun invoke(
passwordValidationType: PasswordValidationType,
password: String,
userId: UserId?
): Flow<ValidatePassword.Result> = observePasswordPolicyValidators(userId)
): Flow<ValidatePassword.Result> = when (passwordValidationType) {
PasswordValidationType.Main -> observePasswordPolicyValidators(userId)
PasswordValidationType.Secondary -> flowOf(emptyList())
}
.catchAll(LogTag.FETCH_PASS_POLICY) { emit(emptyList()) }
.map { policyValidators -> policyValidators.takeIfNotEmpty() ?: listOf(defaultValidator) }
.combine(getRequiredValidators(userId), Collection<PasswordValidator>::plus)
@@ -16,6 +16,7 @@ import me.proton.core.auth.domain.feature.IsCommonPasswordCheckEnabled
import me.proton.core.passvalidator.data.validator.CommonPasswordValidator
import me.proton.core.passvalidator.data.validator.MinLengthPasswordValidator
import me.proton.core.passvalidator.data.validator.PasswordPolicyValidator
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.entity.PasswordValidatorResult
import me.proton.core.presentation.utils.InvalidPasswordProvider
import me.proton.core.test.kotlin.TestDispatcherProvider
@@ -67,7 +68,7 @@ class ValidatePasswordImplTest {
every { minLengthPasswordValidator.validate(any()) } returns validatorResult()
// WHEN
tested("password", null).test {
tested(passwordValidationType = PasswordValidationType.Main, "password", null).test {
val result = awaitItem()
// THEN
@@ -91,7 +92,7 @@ class ValidatePasswordImplTest {
every { isCommonPasswordCheckEnabled(null) } returns true
// WHEN
tested("password", null).test {
tested(passwordValidationType = PasswordValidationType.Main, "password", null).test {
val result = awaitItem()
// THEN
@@ -115,7 +116,7 @@ class ValidatePasswordImplTest {
every { minLengthPasswordValidator.validate(any()) } returns validatorResult(isValid = false)
// WHEN
tested("password", null).test {
tested(passwordValidationType = PasswordValidationType.Main, "password", null).test {
val result = awaitItem()
// THEN
@@ -138,7 +139,7 @@ class ValidatePasswordImplTest {
every { isCommonPasswordCheckEnabled(null) } returns true
// WHEN
tested("password", null).test {
tested(passwordValidationType = PasswordValidationType.Main, "password", null).test {
val result = awaitItem()
// THEN
@@ -153,6 +154,25 @@ class ValidatePasswordImplTest {
}
}
@Test
fun `default validators for secondary password`() = runTest(dispatcherProvider.Main) {
// GIVEN
every { isCommonPasswordCheckEnabled(null) } returns false
every { minLengthPasswordValidator.validate(any()) } returns validatorResult()
// WHEN
tested(passwordValidationType = PasswordValidationType.Secondary, "password", null).test {
val result = awaitItem()
// THEN
assertEquals(1, result.results.size)
assertNotNull(result.token)
awaitComplete()
verify(exactly = 1) { minLengthPasswordValidator.validate(any()) }
}
}
private fun validatorResult(isOptional: Boolean = false, isValid: Boolean = true) = PasswordValidatorResult(
errorMessage = "",
hideIfValid = false,
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.passvalidator.domain.entity
public enum class PasswordValidationType {
/** Validation for the main login password. */
Main,
/** Validation for the secondary (mailbox) password. */
Secondary
}
@@ -20,11 +20,16 @@ package me.proton.core.passvalidator.domain.usecase
import kotlinx.coroutines.flow.Flow
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.entity.PasswordValidatorResult
import me.proton.core.passvalidator.domain.entity.PasswordValidatorToken
public interface ValidatePassword {
public operator fun invoke(password: String, userId: UserId?): Flow<Result>
public operator fun invoke(
passwordValidationType: PasswordValidationType,
password: String,
userId: UserId?
): Flow<Result>
public data class Result(
val results: List<PasswordValidatorResult>,
@@ -47,6 +47,7 @@ import me.proton.core.compose.theme.captionHint
import me.proton.core.compose.theme.captionWeak
import me.proton.core.compose.viewmodel.hiltViewModelOrNull
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.entity.PasswordValidatorToken
import me.proton.core.passvalidator.presentation.R
@@ -71,6 +72,7 @@ public fun LegacyPasswordPolicyReportStyle(): PasswordPolicyReportStyle = Passwo
@Composable
public fun PasswordPolicyReport(
passwordValidationType: PasswordValidationType,
passwordFlow: Flow<String>,
userId: UserId?,
onResult: (token: PasswordValidatorToken?) -> Unit,
@@ -81,6 +83,7 @@ public fun PasswordPolicyReport(
val password by passwordFlow.collectAsStateWithLifecycle(initialValue = "")
PasswordPolicyReport(
passwordValidationType = passwordValidationType,
password = password,
userId = userId,
onResult = onResult,
@@ -92,6 +95,7 @@ public fun PasswordPolicyReport(
@Composable
public fun PasswordPolicyReport(
passwordValidationType: PasswordValidationType,
password: String,
userId: UserId?,
onResult: (token: PasswordValidatorToken?) -> Unit,
@@ -102,7 +106,7 @@ public fun PasswordPolicyReport(
val state by viewModel?.state?.collectAsStateWithLifecycle() ?: return
LaunchedEffect(password) {
viewModel?.perform(PasswordPolicyReportAction.Validate(password, userId))
viewModel?.perform(PasswordPolicyReportAction.Validate(passwordValidationType, password, userId))
}
PasswordPolicyReport(
@@ -19,10 +19,15 @@
package me.proton.core.passvalidator.presentation.report
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
public sealed interface PasswordPolicyReportOperation
public sealed interface PasswordPolicyReportAction : PasswordPolicyReportOperation {
public data object NoOp : PasswordPolicyReportAction
public data class Validate(val password: String, val userId: UserId?) : PasswordPolicyReportAction
public data class Validate(
val passwordValidationType: PasswordValidationType,
val password: String,
val userId: UserId?
) : PasswordPolicyReportAction
}
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import me.proton.core.compose.viewmodel.BaseViewModel
import me.proton.core.domain.entity.UserId
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.entity.PasswordValidatorResult
import me.proton.core.passvalidator.domain.usecase.ValidatePassword
import javax.inject.Inject
@@ -38,7 +39,11 @@ public open class PasswordPolicyReportViewModel @Inject constructor(
) {
override fun onAction(action: PasswordPolicyReportAction): Flow<PasswordPolicyReportState> = when (action) {
is PasswordPolicyReportAction.NoOp -> emptyFlow()
is PasswordPolicyReportAction.Validate -> onValidate(action.password, action.userId)
is PasswordPolicyReportAction.Validate -> onValidate(
passwordValidationType = action.passwordValidationType,
password = action.password,
userId = action.userId
)
}
override suspend fun FlowCollector<PasswordPolicyReportState>.onError(throwable: Throwable) {
@@ -46,9 +51,14 @@ public open class PasswordPolicyReportViewModel @Inject constructor(
}
private fun onValidate(
passwordValidationType: PasswordValidationType,
password: String,
userId: UserId?
): Flow<PasswordPolicyReportState> = validatePassword(password, userId).map { (validationResults, token) ->
): Flow<PasswordPolicyReportState> = validatePassword(
passwordValidationType = passwordValidationType,
password = password,
userId = userId
).map { (validationResults, token) ->
when {
validationResults.isEmpty() -> PasswordPolicyReportState.Hidden(token = token)
else -> {
@@ -7,6 +7,7 @@ import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.domain.entity.PasswordValidatorResult
import me.proton.core.passvalidator.domain.entity.PasswordValidatorToken
import me.proton.core.passvalidator.domain.usecase.ValidatePassword
@@ -33,7 +34,12 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
@Test
fun `empty results`() = runTest {
// GIVEN
every { validatePassword(any(), any()) } returns flowOf(ValidatePassword.Result(emptyList(), FakeToken()))
every { validatePassword(any(), any(), any()) } returns flowOf(
ValidatePassword.Result(
emptyList(),
FakeToken()
)
)
// WHEN
tested.state.test {
@@ -41,7 +47,13 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
assertIs<PasswordPolicyReportState.Loading>(awaitItem())
// WHEN
tested.perform(PasswordPolicyReportAction.Validate(password = "password", userId = null))
tested.perform(
PasswordPolicyReportAction.Validate(
passwordValidationType = PasswordValidationType.Main,
password = "password",
userId = null
)
)
// THEN
assertIs<PasswordPolicyReportState.Hidden>(awaitItem())
@@ -52,7 +64,7 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
@Test
fun `validation results`() = runTest {
// GIVEN
every { validatePassword(any(), any()) } answers {
every { validatePassword(any(), any(), any()) } answers {
flowOf(
ValidatePassword.Result(emptyList(), null),
ValidatePassword.Result(testValidatorResults, null)
@@ -65,7 +77,13 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
assertIs<PasswordPolicyReportState.Loading>(awaitItem())
// WHEN
tested.perform(PasswordPolicyReportAction.Validate(password = "password", userId = null))
tested.perform(
PasswordPolicyReportAction.Validate(
passwordValidationType = PasswordValidationType.Main,
password = "password",
userId = null
)
)
// THEN
awaitItem().let {
@@ -90,7 +108,7 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
@Test
fun `hint with error`() = runTest {
// GIVEN
every { validatePassword(any(), any()) } answers {
every { validatePassword(any(), any(), any()) } answers {
flowOf(
ValidatePassword.Result(emptyList(), FakeToken()),
ValidatePassword.Result(smallResults, FakeToken())
@@ -103,7 +121,13 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
assertIs<PasswordPolicyReportState.Loading>(awaitItem())
// WHEN
tested.perform(PasswordPolicyReportAction.Validate(password = "password", userId = null))
tested.perform(
PasswordPolicyReportAction.Validate(
passwordValidationType = PasswordValidationType.Main,
password = "password",
userId = null
)
)
// THEN
awaitItem().let {
@@ -123,7 +147,7 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
@Test
fun `runtime error`() = runTest {
every { validatePassword(any(), any()) } returns flow {
every { validatePassword(any(), any(), any()) } returns flow {
error("unexpected error")
}
@@ -133,7 +157,13 @@ class PasswordPolicyReportViewModelTest : CoroutinesTest by CoroutinesTest() {
assertIs<PasswordPolicyReportState.Loading>(awaitItem())
// WHEN
tested.perform(PasswordPolicyReportAction.Validate(password = "password", userId = null))
tested.perform(
PasswordPolicyReportAction.Validate(
passwordValidationType = PasswordValidationType.Main,
password = "password",
userId = null
)
)
// THEN
awaitItem().let {
@@ -45,6 +45,7 @@ import me.proton.core.auth.presentation.viewmodel.Source
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.domain.entity.UserId
import me.proton.core.network.presentation.util.getUserMessage
import me.proton.core.passvalidator.domain.entity.PasswordValidationType
import me.proton.core.passvalidator.presentation.report.PasswordPolicyReport
import me.proton.core.presentation.ui.ProtonSecureFragment
import me.proton.core.presentation.ui.view.ProtonInput
@@ -149,6 +150,7 @@ class PasswordManagementFragment :
newLoginPasswordPolicies.setContent {
PasswordPolicyReport(
passwordValidationType = PasswordValidationType.Main,
passwordFlow = newLoginPasswordInput.textChanges().map { it.toString() },
userId = userId,
onResult = { isNewPasswordValid = it != null }