diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/PasswordRequirementsChecklist.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/PasswordRequirementsChecklist.kt new file mode 100644 index 000000000..98971a607 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/PasswordRequirementsChecklist.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.google.firebase.auth.FirebaseAuth + +/** Supplies the project password policy to [SignUpUI]. Must return `null` rather than throw. */ +internal fun interface PasswordPolicySource { + suspend fun getPolicy(context: Context): PasswordPolicy? +} + +/** + * Activation switch for the sign-up password requirements checklist. `null`, the default, keeps + * it off: nothing is fetched and [SignUpUI] renders as before. + */ +internal val LocalPasswordPolicySource = staticCompositionLocalOf { null } + +/** A [PasswordPolicySource] for [auth]'s app and tenant, cached by this repository. */ +internal fun PasswordPolicyRepository.sourceFor(auth: FirebaseAuth) = + PasswordPolicySource { context -> getPolicy(context, auth) } + +/** One checklist line. [symbols] is set only on the special character line. */ +internal data class PasswordRequirement( + val label: String, + val isMet: Boolean, + val symbols: String? = null, +) + +/** + * The requirements [policy] configures, in the JS SDK's order, evaluated against [password]. Max + * length is not shown: its only string reads as an error even when met. + */ +internal fun passwordRequirements( + policy: PasswordPolicy, + password: String, + stringProvider: AuthUIStringProvider, +): List { + val status = policy.validate(password) + return buildList { + add( + PasswordRequirement( + stringProvider.passwordTooShort(policy.minPasswordLength), + status.meetsMinPasswordLength, + ) + ) + status.containsLowercaseLetter?.let { + add(PasswordRequirement(stringProvider.passwordMissingLowercase, it)) + } + status.containsUppercaseLetter?.let { + add(PasswordRequirement(stringProvider.passwordMissingUppercase, it)) + } + status.containsNumericCharacter?.let { + add(PasswordRequirement(stringProvider.passwordMissingDigit, it)) + } + status.containsNonAlphanumericCharacter?.let { + val symbols = policy.allowedNonAlphanumericCharacters + .flatMap { it.codePointSymbols() } + .distinct() + .joinToString(" ") + add( + PasswordRequirement( + stringProvider.passwordMissingSpecialCharacter, + it, + symbols.ifEmpty { null }, + ) + ) + } + } +} + +/** Each code point of this string as its own string, so non-BMP symbols stay intact. */ +private fun String.codePointSymbols(): List { + val symbols = mutableListOf() + var i = 0 + while (i < length) { + val end = offsetByCodePoints(i, 1) + symbols += substring(i, end) + i = end + } + return symbols +} + +/** + * Live checklist of [requirements], from [passwordRequirements]. Hints only: it never affects + * whether sign-up can be submitted. Each line exposes met/unmet as a toggleable state, so it is + * not conveyed by colour. + */ +@Composable +internal fun PasswordRequirementsChecklist( + requirements: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + requirements.forEach { requirement -> + val tint = if (requirement.isMet) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + modifier = Modifier.semantics(mergeDescendants = true) { + toggleableState = ToggleableState(requirement.isMet) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (requirement.isMet) { + Icons.Filled.CheckCircle + } else { + Icons.Outlined.RadioButtonUnchecked + }, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = requirement.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + requirement.symbols?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt index 0c08be1c6..1dadf4413 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt @@ -33,7 +33,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -41,8 +46,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.AuthUITheme import com.firebase.ui.auth.configuration.validators.EmailValidator @@ -76,12 +83,52 @@ fun SignUpUI( val stringProvider = LocalAuthUIStringProvider.current val displayNameValidator = remember { GeneralFieldValidator(stringProvider) } val emailValidator = remember { EmailValidator(stringProvider) } - val passwordValidator = remember { + // Set on the first edit, so a re-key can tell a cleared field from an untouched one. + var passwordEdited by rememberSaveable { mutableStateOf(false) } + // Keyed on the inputs it is built from, so a swapped provider keeps the wording in sync. + // They are stable while typing, so the validator is not rebuilt per keystroke. + val passwordValidator = remember( + stringProvider, + provider.minimumPasswordLength, + provider.passwordValidationRules, + ) { PasswordValidator( stringProvider = stringProvider, - rules = provider.passwordValidationRules - ) + // Same order as the submit path: minimumPasswordLength first, then the custom rules. + // The validator reports only the first failing rule, so there is one message at most. + rules = listOf(PasswordRule.MinimumLength(provider.minimumPasswordLength)) + + provider.passwordValidationRules + ).also { + // A new instance starts without an error, so re-validate typed input to keep it shown. + // Only an untouched field stays unseeded, so a re-key adds no error to it. + if (password.isNotEmpty() || passwordEdited) it.validate(password) + } + } + val passwordPolicySource = LocalPasswordPolicySource.current + // Hints only (never part of isFormValid). No source, no policy or OFF: nothing is shown. + val passwordPolicy by produceState(null, passwordPolicySource, context) { + value = passwordPolicySource?.getPolicy(context) + ?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE } + } + val checklistRequirements = remember(passwordPolicy, password, stringProvider) { + passwordPolicy?.let { passwordRequirements(it, password, stringProvider) } } + // Only unmet lines: local and policy symbol sets differ, so a met line can share a failing + // local rule's label, and blanking it would leave no reason shown. For the same reason the + // special character message is never blanked while a local special character rule exists: + // the policy may accept a symbol the local rule rejects. + val hasLocalSpecialRule = + PasswordRule.RequireSpecialCharacter in provider.passwordValidationRules + val unmetChecklistLabels = + remember(checklistRequirements, hasLocalSpecialRule, stringProvider) { + checklistRequirements.orEmpty() + .filterNot { it.isMet } + .map { it.label } + .filterNot { + hasLocalSpecialRule && it == stringProvider.passwordMissingSpecialCharacter + } + .toSet() + } val confirmPasswordValidator = remember(password) { GeneralFieldValidator( stringProvider = stringProvider, @@ -92,7 +139,13 @@ fun SignUpUI( ) } - val isFormValid = remember(displayName, email, password, confirmPassword) { + val isFormValid = remember( + displayName, + email, + password, + confirmPassword, + passwordValidator, + ) { derivedStateOf { listOf( !provider.isDisplayNameRequired || displayNameValidator.validate(displayName), @@ -166,18 +219,26 @@ fun SignUpUI( modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD), value = password, validator = passwordValidator, + // A message an unmet checklist line already shows is not repeated. isError still + // marks the field, so the error remains in its semantics. + errorMessage = "".takeIf { passwordValidator.errorMessage in unmetChecklistLabels }, enabled = !isLoading, isSecureTextField = true, label = { Text(stringProvider.passwordHint) }, onValueChange = { text -> + passwordEdited = true onPasswordChange(text) }, visibilityToggleModifier = Modifier.testTag( FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE ) ) + checklistRequirements?.let { requirements -> + Spacer(modifier = Modifier.height(8.dp)) + PasswordRequirementsChecklist(requirements = requirements) + } Spacer(modifier = Modifier.height(16.dp)) AuthTextField( modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD), diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUIPasswordPolicyTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUIPasswordPolicyTest.kt new file mode 100644 index 000000000..fdf91ce24 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUIPasswordPolicyTest.kt @@ -0,0 +1,482 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.PasswordRule +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.password_policy.CertificateHashProvider +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy.EnforcementState +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyHttpClient +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.Dispatchers +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for the password requirements checklist [SignUpUI] shows from the project password + * policy, and that it never blocks submission. + * + * @suppress Internal test class + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SignUpUIPasswordPolicyTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private var signUpClicks = 0 + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + } + + private fun policy( + minPasswordLength: Int = 8, + maxPasswordLength: Int? = null, + lowercase: Boolean = false, + uppercase: Boolean = false, + numeric: Boolean = false, + symbol: Boolean = false, + symbols: List = emptyList(), + enforcementState: EnforcementState = EnforcementState.ENFORCE, + ) = PasswordPolicy( + minPasswordLength = minPasswordLength, + maxPasswordLength = maxPasswordLength, + containsLowercaseCharacter = lowercase, + containsUppercaseCharacter = uppercase, + containsNumericCharacter = numeric, + containsNonAlphanumericCharacter = symbol, + allowedNonAlphanumericCharacters = symbols, + enforcementState = enforcementState, + ) + + private fun setContent( + source: PasswordPolicySource?, + rules: List = emptyList(), + minimumPasswordLength: Int = 6, + ) = setContent(rules, minimumPasswordLength) { content -> + CompositionLocalProvider(LocalPasswordPolicySource provides source) { content() } + } + + /** Composes [SignUpUI] inside [wrap], which decides whether a policy source is provided. */ + private fun setContent( + rules: List = emptyList(), + minimumPasswordLength: Int = 6, + wrap: @Composable (content: @Composable () -> Unit) -> Unit, + ) { + val provider = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + minimumPasswordLength = minimumPasswordLength, + passwordValidationRules = rules, + ) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + wrap { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = email, + password = password, + confirmPassword = confirmPassword, + onDisplayNameChange = { }, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { confirmPassword = it }, + onSignUpClick = { signUpClicks++ }, + ) + } + } + } + composeTestRule.waitForIdle() + } + + // Checklist lines are the only toggleable nodes on the screen, which also tells them apart + // from the password field's own error text. Main sources may only tag with FirebaseAuthTestTags. + private val isChecklistLine = SemanticsMatcher.keyIsDefined(SemanticsProperties.ToggleableState) + + private fun checklistLine(label: String) = + composeTestRule.onNode(hasText(label) and isChecklistLine) + + private fun assertNoChecklist() { + composeTestRule.onAllNodes(isChecklistLine).assertCountEquals(0) + } + + private fun fillForm(password: String) { + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("test@example.com") + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextInput(password) + composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint) + .performTextInput(password) + composeTestRule.waitForIdle() + } + + private val signUpButton + get() = composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON) + + private fun typePassword(password: String) { + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextInput(password) + composeTestRule.waitForIdle() + } + + private fun assertPasswordFieldIsError() { + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD) + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Error)) + } + + // ============================================================================================= + // Hidden + // ============================================================================================= + + @Test + fun `checklist is hidden when the switch is off`() { + setContent(source = null) + assertNoChecklist() + } + + @Test + fun `checklist is off by default when no source is provided`() { + // Sentinel, so the assertion fails if the local is never read. + var current: PasswordPolicySource? = PasswordPolicySource { null } + setContent { content -> + current = LocalPasswordPolicySource.current + content() + } + assertNoChecklist() + assertThat(current).isNull() + } + + @Test + fun `checklist is hidden when no policy is available`() { + var calls = 0 + setContent(source = PasswordPolicySource { calls++; null }) + assertThat(calls).isEqualTo(1) + assertNoChecklist() + } + + @Test + fun `checklist is hidden when the policy is OFF`() { + val off = policy(uppercase = true, enforcementState = EnforcementState.OFF) + setContent(source = PasswordPolicySource { off }) + assertNoChecklist() + } + + // ============================================================================================= + // Rendered lines + // ============================================================================================= + + @Test + fun `ENFORCE policy shows only its configured requirements with the allowed symbols`() { + val enforced = policy( + minPasswordLength = 8, + maxPasswordLength = 20, + uppercase = true, + symbol = true, + symbols = listOf("^$", "*", "!"), + ) + setContent(source = PasswordPolicySource { enforced }) + + checklistLine(stringProvider.passwordTooShort(8)).assertExists() + checklistLine(stringProvider.passwordTooLong(20)).assertDoesNotExist() + checklistLine(stringProvider.passwordMissingUppercase).assertExists() + checklistLine(stringProvider.passwordMissingSpecialCharacter).assertExists() + checklistLine(stringProvider.passwordMissingLowercase).assertDoesNotExist() + checklistLine(stringProvider.passwordMissingDigit).assertDoesNotExist() + composeTestRule.onNodeWithText("^ $ * !", useUnmergedTree = true).assertExists() + checklistLine("^ $ * !").assertExists() + } + + @Test + fun `allowed symbols are split by code point and de-duplicated`() { + val grinning = "\uD83D\uDE00" + val enforced = policy(symbol = true, symbols = listOf("$grinning!", grinning, "~")) + val symbolLine = passwordRequirements(enforced, "", stringProvider).single { + it.label == stringProvider.passwordMissingSpecialCharacter + } + assertThat(symbolLine.symbols).isEqualTo("$grinning ! ~") + } + + @Test + fun `max length line is not shown even when the policy sets one`() { + val enforced = policy( + minPasswordLength = 8, + maxPasswordLength = 4096, + lowercase = true, + numeric = true, + ) + setContent(source = PasswordPolicySource { enforced }) + + checklistLine(stringProvider.passwordTooShort(8)).assertExists() + checklistLine(stringProvider.passwordMissingLowercase).assertExists() + checklistLine(stringProvider.passwordMissingDigit).assertExists() + composeTestRule.onAllNodesWithText(stringProvider.passwordTooLong(4096)) + .assertCountEquals(0) + checklistLine(stringProvider.passwordMissingUppercase).assertDoesNotExist() + checklistLine(stringProvider.passwordMissingSpecialCharacter).assertDoesNotExist() + composeTestRule.onAllNodes(isChecklistLine).assertCountEquals(3) + } + + @Test + fun `lines report met and unmet live as the password changes`() { + val enforced = policy( + minPasswordLength = 8, + uppercase = true, + symbol = true, + symbols = listOf("!"), + ) + setContent(source = PasswordPolicySource { enforced }) + val minLine = checklistLine(stringProvider.passwordTooShort(8)) + val upperLine = checklistLine(stringProvider.passwordMissingUppercase) + val symbolLine = checklistLine(stringProvider.passwordMissingSpecialCharacter) + + minLine.assertIsOff() + upperLine.assertIsOff() + symbolLine.assertIsOff() + + val passwordField = composeTestRule.onNodeWithText(stringProvider.passwordHint) + passwordField.performTextInput("Abcdefgh!") + composeTestRule.waitForIdle() + minLine.assertIsOn() + upperLine.assertIsOn() + symbolLine.assertIsOn() + + passwordField.performTextReplacement("abcdefghijk") + composeTestRule.waitForIdle() + minLine.assertIsOn() + upperLine.assertIsOff() + symbolLine.assertIsOff() + } + + // ============================================================================================= + // Never blocking + // ============================================================================================= + + @Test + fun `unmet ENFORCE requirements do not block sign-up when local rules pass`() { + val enforced = policy(minPasswordLength = 12, uppercase = true, symbol = true) + setContent(source = PasswordPolicySource { enforced }) + + fillForm("password1") + checklistLine(stringProvider.passwordTooShort(12)).assertIsOff() + checklistLine(stringProvider.passwordMissingUppercase).assertIsOff() + + signUpButton.assertIsEnabled() + signUpButton.performScrollTo().performClick() + assertThat(signUpClicks).isEqualTo(1) + } + + @Test + fun `local rules still block sign-up while the checklist is shown`() { + val enforced = policy(minPasswordLength = 6) + setContent( + source = PasswordPolicySource { enforced }, + rules = listOf(PasswordRule.RequireDigit), + ) + + fillForm("password") + checklistLine(stringProvider.passwordTooShort(6)).assertIsOn() + signUpButton.assertIsNotEnabled() + composeTestRule.onNodeWithText(stringProvider.passwordMissingDigit).assertExists() + } + + // ============================================================================================= + // Field message vs checklist + // ============================================================================================= + + @Test + fun `field message matching a checklist line is not repeated but the field stays in error`() { + val enforced = policy(minPasswordLength = 6, uppercase = true) + setContent( + source = PasswordPolicySource { enforced }, + rules = listOf(PasswordRule.RequireUppercase), + minimumPasswordLength = 6, + ) + + typePassword("abc") + composeTestRule.onAllNodesWithText(stringProvider.passwordTooShort(6)) + .assertCountEquals(1) + checklistLine(stringProvider.passwordTooShort(6)).assertIsOff() + assertPasswordFieldIsError() + + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextReplacement("abcdef") + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText(stringProvider.passwordMissingUppercase) + .assertCountEquals(1) + checklistLine(stringProvider.passwordMissingUppercase).assertIsOff() + assertPasswordFieldIsError() + signUpButton.assertIsNotEnabled() + } + + @Test + fun `field message matching a met checklist line is still shown`() { + // The policy accepts `~`, the local rule does not, so only the local rule fails. + val enforced = policy(minPasswordLength = 6, symbol = true, symbols = listOf("~")) + setContent( + source = PasswordPolicySource { enforced }, + rules = listOf(PasswordRule.RequireSpecialCharacter), + ) + + fillForm("Abcdef1~") + checklistLine(stringProvider.passwordMissingSpecialCharacter).assertIsOn() + composeTestRule.onNode( + hasText(stringProvider.passwordMissingSpecialCharacter) and !isChecklistLine + ).assertExists() + assertPasswordFieldIsError() + signUpButton.assertIsNotEnabled() + } + + @Test + fun `special character message is not blanked beside an unmet symbol line with a local rule`() { + // The policy may accept a symbol the local rule rejects, so the local message stays. + val enforced = policy(minPasswordLength = 6, symbol = true, symbols = listOf("~", "!")) + setContent( + source = PasswordPolicySource { enforced }, + rules = listOf(PasswordRule.RequireSpecialCharacter), + ) + + fillForm("Abcdef1") + checklistLine(stringProvider.passwordMissingSpecialCharacter).assertIsOff() + composeTestRule.onNode( + hasText(stringProvider.passwordMissingSpecialCharacter) and !isChecklistLine + ).assertExists() + assertPasswordFieldIsError() + signUpButton.assertIsNotEnabled() + } + + @Test + fun `field message absent from the checklist is still shown`() { + val enforced = policy(minPasswordLength = 8) + setContent(source = PasswordPolicySource { enforced }, minimumPasswordLength = 6) + + typePassword("abc") + checklistLine(stringProvider.passwordTooShort(8)).assertIsOff() + composeTestRule.onNode(hasText(stringProvider.passwordTooShort(6)) and !isChecklistLine) + .assertExists() + assertPasswordFieldIsError() + } + + @Test + fun `field message shows as before when the switch is off`() { + setContent(source = null, minimumPasswordLength = 6) + + typePassword("abc") + assertNoChecklist() + composeTestRule.onAllNodesWithText(stringProvider.passwordTooShort(6)) + .assertCountEquals(1) + assertPasswordFieldIsError() + } + + // ============================================================================================= + // Repository-backed source + // ============================================================================================= + + @Test + fun `repository source fetches once and shows the fetched policy`() { + val requests = mutableListOf() + val repository = PasswordPolicyRepository( + httpClient = PasswordPolicyHttpClient { url, _ -> + requests += url + PasswordPolicyHttpClient.HttpResponse(200, ENFORCE_RESPONSE) + }, + certificateHash = CertificateHashProvider { _, _ -> null }, + ioDispatcher = Dispatchers.Unconfined, + ) + setContent(source = repository.sourceFor(auth())) + + assertThat(requests).hasSize(1) + checklistLine(stringProvider.passwordTooShort(10)).assertExists() + checklistLine(stringProvider.passwordMissingDigit).assertExists() + } + + private fun auth(): FirebaseAuth { + val options = FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("1:123:android:abc") + .setProjectId("test-project") + .build() + val app = mock { + on { name } doReturn "[DEFAULT]" + on { this.options } doReturn options + } + return mock { + on { this.app } doReturn app + on { tenantId } doReturn null + } + } + + private companion object { + const val ENFORCE_RESPONSE = """ + { + "customStrengthOptions": {"minPasswordLength": 10, "containsNumericCharacter": true}, + "enforcementState": "ENFORCE", + "schemaVersion": 1 + } + """ + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt index ef716c8ae..b0814dc04 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/SignUpUITest.kt @@ -20,18 +20,30 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.ui.FirebaseAuthTestTags import org.junit.Before import org.junit.Rule import org.junit.Test @@ -208,4 +220,174 @@ class SignUpUITest { composeTestRule.onNodeWithText(stringProvider.invalidEmailAddress).assertExists() } + + private fun setSignUpContent(provider: AuthProvider.Email) { + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + } + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = email, + password = password, + confirmPassword = confirmPassword, + onDisplayNameChange = { }, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { confirmPassword = it }, + onSignUpClick = { } + ) + } + } + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("test@example.com") + } + + private fun enterPasswords(password: String) { + composeTestRule.onNodeWithText(stringProvider.passwordHint) + .performTextReplacement(password) + composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint) + .performTextReplacement(password) + composeTestRule.waitForIdle() + } + + @Test + fun `live password validation honours minimumPasswordLength`() { + setSignUpContent( + AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + minimumPasswordLength = 10, + passwordValidationRules = emptyList() + ) + ) + + // The submit path rejects this password, so the live validator must too. + enterPasswords("short123") + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON) + .assertIsNotEnabled() + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(10)).assertExists() + + enterPasswords("long123456") + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON) + .assertIsEnabled() + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(10)).assertDoesNotExist() + } + + @Test + fun `minimumPasswordLength and a custom length rule surface a single message`() { + setSignUpContent( + AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + minimumPasswordLength = 10, + passwordValidationRules = listOf(PasswordRule.MinimumLength(12)) + ) + ) + + // Checked in the submit path's order, so minimumPasswordLength reports first and alone. + enterPasswords("short123") + composeTestRule.onAllNodesWithText(stringProvider.passwordTooShort(10)) + .assertCountEquals(1) + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(12)).assertDoesNotExist() + + enterPasswords("long123456") + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(10)).assertDoesNotExist() + composeTestRule.onAllNodesWithText(stringProvider.passwordTooShort(12)) + .assertCountEquals(1) + } + + // A fresh MinimumLength instance has no equals, so each call re-keys the password validator. + private fun equivalentConfiguration(): AuthUIConfiguration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + minimumPasswordLength = 6, + passwordValidationRules = listOf(PasswordRule.MinimumLength(12)) + ) + ) + } + } + + private fun setSwappableContent(): (AuthUIConfiguration) -> Unit { + var configuration by mutableStateOf(equivalentConfiguration()) + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + var password by remember { mutableStateOf("") } + SignUpUI( + configuration = configuration, + isLoading = false, + displayName = "", + email = "", + password = password, + confirmPassword = "", + onDisplayNameChange = { }, + onEmailChange = { }, + onPasswordChange = { password = it }, + onConfirmPasswordChange = { }, + onSignUpClick = { } + ) + } + } + return { configuration = it } + } + + private val isInError = SemanticsMatcher.keyIsDefined(SemanticsProperties.Error) + + @Test + fun `password error survives an equivalent configuration swap`() { + val swap = setSwappableContent() + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextInput("short1") + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD).assert(isInError) + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(12)).assertExists() + + swap(equivalentConfiguration()) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD).assert(isInError) + composeTestRule.onNodeWithText(stringProvider.passwordTooShort(12)).assertExists() + } + + @Test + fun `cleared password error survives an equivalent configuration swap`() { + val swap = setSwappableContent() + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextInput("short1") + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD) + .performTextClearance() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD).assert(isInError) + composeTestRule.onNodeWithText(stringProvider.invalidPassword).assertExists() + + // The field is empty again, so only the edit history tells it apart from an untouched one. + swap(equivalentConfiguration()) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD).assert(isInError) + composeTestRule.onNodeWithText(stringProvider.invalidPassword).assertExists() + } + + @Test + fun `untouched password field gains no error from a configuration swap`() { + val swap = setSwappableContent() + composeTestRule.waitForIdle() + + swap(equivalentConfiguration()) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD) + .assert(!isInError) + composeTestRule.onNodeWithText(stringProvider.invalidPassword).assertDoesNotExist() + } }