diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt index fe5bbf302..0e7080722 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIStringProvider.kt @@ -23,19 +23,59 @@ import com.firebase.ui.auth.R * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. */ interface AuthUIStringProvider { - fun initializing(): String - fun signInWithGoogle(): String - fun invalidEmail(): String - fun passwordsDoNotMatch(): String -} + /** Loading text displayed during initialization or processing states */ + val initializing: String + + /** Button text for Google sign-in option */ + val signInWithGoogle: String + + /** Error message when email address field is empty */ + val missingEmailAddress: String + + /** Error message when email address format is invalid */ + val invalidEmailAddress: String + + /** Generic error message for incorrect password during sign-in */ + val invalidPassword: String -class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { - override fun initializing(): String = "" + /** Error message when password confirmation doesn't match the original password */ + val passwordsDoNotMatch: String - override fun signInWithGoogle(): String = - context.getString(R.string.fui_sign_in_with_google) + /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ + val passwordTooShort: String - override fun invalidEmail(): String = "" + /** Error message when password is missing at least one uppercase letter (A-Z) */ + val passwordMissingUppercase: String + + /** Error message when password is missing at least one lowercase letter (a-z) */ + val passwordMissingLowercase: String + + /** Error message when password is missing at least one numeric digit (0-9) */ + val passwordMissingDigit: String + + /** Error message when password is missing at least one special character */ + val passwordMissingSpecialCharacter: String +} - override fun passwordsDoNotMatch(): String = "" +internal class DefaultAuthUIStringProvider(private val context: Context) : AuthUIStringProvider { + override val initializing: String get() = "" + override val signInWithGoogle: String + get() = context.getString(R.string.fui_sign_in_with_google) + override val missingEmailAddress: String + get() = context.getString(R.string.fui_missing_email_address) + override val invalidEmailAddress: String + get() = context.getString(R.string.fui_invalid_email_address) + override val invalidPassword: String + get() = context.getString(R.string.fui_error_invalid_password) + override val passwordsDoNotMatch: String get() = "" + override val passwordTooShort: String + get() = context.getString(R.string.fui_error_password_too_short) + override val passwordMissingUppercase: String + get() = context.getString(R.string.fui_error_password_missing_uppercase) + override val passwordMissingLowercase: String + get() = context.getString(R.string.fui_error_password_missing_lowercase) + override val passwordMissingDigit: String + get() = context.getString(R.string.fui_error_password_missing_digit) + override val passwordMissingSpecialCharacter: String + get() = context.getString(R.string.fui_error_password_missing_special_character) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt index 242ea6e83..8f53822f2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/PasswordRule.kt @@ -22,31 +22,100 @@ abstract class PasswordRule { /** * Requires the password to have at least a certain number of characters. */ - class MinimumLength(val value: Int) : PasswordRule() + class MinimumLength(val value: Int) : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.length >= this@MinimumLength.value + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordTooShort.format(value) + } + } /** * Requires the password to contain at least one uppercase letter (A-Z). */ - object RequireUppercase : PasswordRule() + object RequireUppercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isUpperCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingUppercase + } + } /** * Requires the password to contain at least one lowercase letter (a-z). */ - object RequireLowercase: PasswordRule() + object RequireLowercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isLowerCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingLowercase + } + } /** * Requires the password to contain at least one numeric digit (0-9). */ - object RequireDigit: PasswordRule() + object RequireDigit : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isDigit() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingDigit + } + } /** * Requires the password to contain at least one special character (e.g., !@#$%^&*). */ - object RequireSpecialCharacter: PasswordRule() + object RequireSpecialCharacter : PasswordRule() { + private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() + + override fun isValid(password: String): Boolean { + return password.any { it in specialCharacters } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingSpecialCharacter + } + } /** * Defines a custom validation rule using a regular expression and provides a specific error * message on failure. */ - class Custom(val regex: Regex, val errorMessage: String) + class Custom( + val regex: Regex, + val errorMessage: String + ) : PasswordRule() { + override fun isValid(password: String): Boolean { + return regex.matches(password) + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return errorMessage + } + } + + /** + * Validates whether the given password meets this rule's requirements. + * + * @param password The password to validate + * @return true if the password meets this rule's requirements, false otherwise + */ + internal abstract fun isValid(password: String): Boolean + + /** + * Returns the appropriate error message for this rule when validation fails. + * + * @param stringProvider The string provider for localized error messages + * @return The localized error message for this rule + */ + internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt new file mode 100644 index 000000000..d6b66194f --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidator.kt @@ -0,0 +1,48 @@ +/* + * 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.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider + +internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingEmailAddress + ) + return false + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidEmailAddress + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt new file mode 100644 index 000000000..7a681c921 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidationStatus.kt @@ -0,0 +1,24 @@ +/* + * 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.compose.configuration.validators + +/** + * Class for encapsulating [hasError] and [errorMessage] properties in + * internal FieldValidator subclasses. + */ +internal class FieldValidationStatus( + val hasError: Boolean, + val errorMessage: String? = null, +) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt new file mode 100644 index 000000000..a26741897 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/FieldValidator.kt @@ -0,0 +1,39 @@ +/* + * 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.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider + +/** + * An interface for validating input fields. + */ +interface FieldValidator { + val stringProvider: AuthUIStringProvider + + /** + * Returns true if the last validation failed. + */ + val hasError: Boolean + + /** + * The error message for the current state. + */ + val errorMessage: String + + /** + * Runs validation on a value and returns true if valid. + */ + fun validate(value: String): Boolean +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt new file mode 100644 index 000000000..35605818e --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidator.kt @@ -0,0 +1,54 @@ +/* + * 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.compose.configuration.validators + +import com.firebase.ui.auth.compose.configuration.AuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule + +internal class PasswordValidator( + override val stringProvider: AuthUIStringProvider, + private val rules: List +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPassword + ) + return false + } + + for (rule in rules) { + if (!rule.isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = rule.getErrorMessage(stringProvider) + ) + return false + } + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 52314a505..71b87f547 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -93,6 +93,13 @@ An unknown error occurred. Incorrect password. + + Password must be at least %1$d characters long + Password must contain at least one uppercase letter + Password must contain at least one lowercase letter + Password must contain at least one number + Password must contain at least one special character + App logo diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index c8e627ff5..0233848e4 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -20,12 +20,23 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.auth.actionCodeSettings import org.junit.Assert.assertThrows import org.junit.Test +import org.mockito.Mockito.mock import java.util.Locale import kotlin.reflect.KMutableProperty import kotlin.reflect.full.memberProperties +/** + * Unit tests for [AuthUIConfiguration] covering configuration builder behavior, + * validation rules, provider setup, and immutability guarantees. + * + * @suppress Internal test class + */ class AuthUIConfigurationTest { + // ============================================================================================= + // Basic Configuration Tests + // ============================================================================================= + @Test fun `authUIConfiguration with minimal setup uses correct defaults`() { val config = authUIConfiguration { @@ -58,12 +69,7 @@ class AuthUIConfigurationTest { @Test fun `authUIConfiguration with all fields overridden uses custom values`() { val customTheme = AuthUITheme.Default - val customStringProvider = object : AuthUIStringProvider { - override fun initializing(): String = "" - override fun signInWithGoogle(): String = "" - override fun invalidEmail(): String = "" - override fun passwordsDoNotMatch(): String = "" - } + val customStringProvider = mock(AuthUIStringProvider::class.java) val customLocale = Locale.US val customActionCodeSettings = actionCodeSettings { url = "https://example.com/verify" @@ -115,9 +121,9 @@ class AuthUIConfigurationTest { assertThat(config.isProviderChoiceAlwaysShown).isTrue() } - // =========================================================================================== + // ============================================================================================= // Validation Tests - // =========================================================================================== + // ============================================================================================= @Test(expected = IllegalArgumentException::class) fun `authUIConfiguration throws when no providers configured`() { @@ -136,7 +142,12 @@ class AuthUIConfigurationTest { provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) - provider(AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = listOf())) + provider( + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } assertThat(config.providers).hasSize(9) @@ -174,7 +185,12 @@ class AuthUIConfigurationTest { authUIConfiguration { providers { provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider(AuthProvider.Google(scopes = listOf("email"), serverClientId = "different")) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "different" + ) + ) } } } @@ -183,11 +199,13 @@ class AuthUIConfigurationTest { fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = null, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) } } } @@ -200,18 +218,20 @@ class AuthUIConfigurationTest { } authUIConfiguration { providers { - provider(AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = customActionCodeSettings, - passwordValidationRules = listOf() - )) + provider( + AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = customActionCodeSettings, + passwordValidationRules = listOf() + ) + ) } } } - // =========================================================================================== + // ============================================================================================= // Provider Configuration Tests - // =========================================================================================== + // ============================================================================================= @Test fun `providers block can be called multiple times and accumulates providers`() { @@ -238,9 +258,9 @@ class AuthUIConfigurationTest { assertThat(config.providers).hasSize(2) } - // =========================================================================================== + // ============================================================================================= // Builder Immutability Tests - // =========================================================================================== + // ============================================================================================= @Test fun `authUIConfiguration providers list is immutable`() { diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt new file mode 100644 index 000000000..d3cacb488 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/PasswordRuleTest.kt @@ -0,0 +1,336 @@ +/* + * 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.compose.configuration + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [PasswordRule] implementations covering validation logic + * and error message generation for each password rule type. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordRuleTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // MinimumLength Rule Tests + // ============================================================================================= + + @Test + fun `MinimumLength isValid returns true for password meeting length requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `MinimumLength isValid returns false for password shorter than requirement`() { + val rule = PasswordRule.MinimumLength(8) + + val isValid = rule.isValid("short") + + assertThat(isValid).isFalse() + } + + @Test + fun `MinimumLength getErrorMessage returns formatted message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.MinimumLength(10) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_too_short, 10)) + } + + // ============================================================================================= + // RequireUppercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireUppercase isValid returns true for password with uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireUppercase isValid returns false for password without uppercase letter`() { + val rule = PasswordRule.RequireUppercase + + val isValid = rule.isValid("password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireUppercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireUppercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + // ============================================================================================= + // RequireLowercase Rule Tests + // ============================================================================================= + + @Test + fun `RequireLowercase isValid returns true for password with lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireLowercase isValid returns false for password without lowercase letter`() { + val rule = PasswordRule.RequireLowercase + + val isValid = rule.isValid("PASSWORD123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireLowercase getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireLowercase + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + // ============================================================================================= + // RequireDigit Rule Tests + // ============================================================================================= + + @Test + fun `RequireDigit isValid returns true for password with digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireDigit isValid returns false for password without digit`() { + val rule = PasswordRule.RequireDigit + + val isValid = rule.isValid("Password") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireDigit getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireDigit + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + // ============================================================================================= + // RequireSpecialCharacter Rule Tests + // ============================================================================================= + + @Test + fun `RequireSpecialCharacter isValid returns true for password with special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123!") + + assertThat(isValid).isTrue() + } + + @Test + fun `RequireSpecialCharacter isValid returns false for password without special character`() { + val rule = PasswordRule.RequireSpecialCharacter + + val isValid = rule.isValid("Password123") + + assertThat(isValid).isFalse() + } + + @Test + fun `RequireSpecialCharacter getErrorMessage returns correct message`() { + val context = ApplicationProvider.getApplicationContext() + val rule = PasswordRule.RequireSpecialCharacter + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + @Test + fun `RequireSpecialCharacter validates various special characters`() { + val rule = PasswordRule.RequireSpecialCharacter + val specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + + specialChars.forEach { char -> + val isValid = rule.isValid("Password123$char") + assertThat(isValid).isTrue() + } + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `Custom rule isValid works with provided regex`() { + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + + val validPassword = rule.isValid("Password123") + val invalidPassword = rule.isValid("weak") + + assertThat(validPassword).isTrue() + assertThat(invalidPassword).isFalse() + } + + @Test + fun `Custom rule getErrorMessage returns custom message`() { + val customMessage = "Custom validation failed" + val rule = PasswordRule.Custom( + regex = Regex(".*"), + errorMessage = customMessage + ) + + val message = rule.getErrorMessage(stringProvider) + + assertThat(message).isEqualTo(customMessage) + } + + @Test + fun `Custom rule with complex regex works correctly`() { + // Must contain at least one letter, one number, and be 6+ characters + val rule = PasswordRule.Custom( + regex = Regex("^(?=.*[a-zA-Z])(?=.*\\d).{6,}$"), + errorMessage = "Must contain letter and number, min 6 chars" + ) + + assertThat(rule.isValid("abc123")).isTrue() + assertThat(rule.isValid("password1")).isTrue() + assertThat(rule.isValid("123456")).isFalse() // No letter + assertThat(rule.isValid("abcdef")).isFalse() // No number + assertThat(rule.isValid("abc12")).isFalse() // Too short + } + + // ============================================================================================= + // Rule Extensibility Tests + // ============================================================================================= + + @Test + fun `custom password rule by extending PasswordRule works`() { + val customRule = object : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.contains("test") + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return "Password must contain 'test'" + } + } + + val validResult = customRule.isValid("testing123") + val invalidResult = customRule.isValid("invalid") + val errorMessage = customRule.getErrorMessage(stringProvider) + + assertThat(validResult).isTrue() + assertThat(invalidResult).isFalse() + assertThat(errorMessage).isEqualTo("Password must contain 'test'") + } + + @Test + fun `multiple custom rules can be created independently`() { + val rule1 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.startsWith("prefix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must start with 'prefix'" + } + + val rule2 = object : PasswordRule() { + override fun isValid(password: String): Boolean = password.endsWith("suffix") + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String = "Must end with 'suffix'" + } + + assertThat(rule1.isValid("prefixPassword")).isTrue() + assertThat(rule1.isValid("passwordsuffix")).isFalse() + + assertThat(rule2.isValid("passwordsuffix")).isTrue() + assertThat(rule2.isValid("prefixPassword")).isFalse() + + assertThat(rule1.getErrorMessage(stringProvider)).isEqualTo("Must start with 'prefix'") + assertThat(rule2.getErrorMessage(stringProvider)).isEqualTo("Must end with 'suffix'") + } + + // ============================================================================================= + // Edge Case Tests + // ============================================================================================= + + @Test + fun `all rules handle empty password correctly`() { + val rules = listOf( + PasswordRule.MinimumLength(1), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + + rules.forEach { rule -> + val isValid = rule.isValid("") + assertThat(isValid).isFalse() + } + } + + @Test + fun `MinimumLength with zero length allows any password`() { + val rule = PasswordRule.MinimumLength(0) + + assertThat(rule.isValid("")).isTrue() + assertThat(rule.isValid("any")).isTrue() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt new file mode 100644 index 000000000..520908181 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/EmailValidatorTest.kt @@ -0,0 +1,109 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [EmailValidator] covering email validation logic, + * error state management, and integration with [DefaultAuthUIStringProvider] + * using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EmailValidatorTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + + private lateinit var emailValidator: EmailValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + emailValidator = EmailValidator(stringProvider) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Validation Logic Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty email`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_missing_email_address)) + } + + @Test + fun `validate returns false and sets error for invalid email format`() { + val context = ApplicationProvider.getApplicationContext() + + val isValid = emailValidator.validate("invalid-email") + + assertThat(isValid).isFalse() + assertThat(emailValidator.hasError).isTrue() + assertThat(emailValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_invalid_email_address)) + } + + @Test + fun `validate returns true and clears error for valid email`() { + val isValid = emailValidator.validate("test@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } + + @Test + fun `validate clears previous error when valid email provided`() { + emailValidator.validate("invalid") + assertThat(emailValidator.hasError).isTrue() + + val isValid = emailValidator.validate("valid@example.com") + + assertThat(isValid).isTrue() + assertThat(emailValidator.hasError).isFalse() + assertThat(emailValidator.errorMessage).isEmpty() + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt new file mode 100644 index 000000000..4e8d2e440 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/validators/PasswordValidatorTest.kt @@ -0,0 +1,277 @@ +/* + * 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.compose.configuration.validators + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.R +import com.firebase.ui.auth.compose.configuration.DefaultAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Integration tests for [PasswordValidator] covering password validation logic, + * password rule enforcement, error state management, and integration with + * [DefaultAuthUIStringProvider] using real Android string resources. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PasswordValidatorTest { + + private lateinit var stringProvider: DefaultAuthUIStringProvider + private lateinit var passwordValidator: PasswordValidator + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + } + + // ============================================================================================= + // Initial State Tests + // ============================================================================================= + + @Test + fun `validator initial state has no error`() { + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Empty Password Validation Tests + // ============================================================================================= + + @Test + fun `validate returns false and sets error for empty password`() { + val context = ApplicationProvider.getApplicationContext() + passwordValidator = PasswordValidator(stringProvider, emptyList()) + + val isValid = passwordValidator.validate("") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_invalid_password)) + } + + // ============================================================================================= + // Minimum Length Rule Tests + // ============================================================================================= + + @Test + fun `validate returns false for password shorter than minimum length`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting minimum length`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Character Requirement Tests + // ============================================================================================= + + @Test + fun `validate returns false for password missing uppercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_uppercase)) + } + + @Test + fun `validate returns true for password with uppercase letter`() { + val rules = listOf(PasswordRule.RequireUppercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns false for password missing lowercase letter`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireLowercase) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("PASSWORD123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_lowercase)) + } + + @Test + fun `validate returns false for password missing digit`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireDigit) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_digit)) + } + + @Test + fun `validate returns false for password missing special character`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf(PasswordRule.RequireSpecialCharacter) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_missing_special_character)) + } + + // ============================================================================================= + // Multiple Rules Tests + // ============================================================================================= + + @Test + fun `validate returns false and shows first failing rule error`() { + val context = ApplicationProvider.getApplicationContext() + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireDigit + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("short") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + // Should show the first failing rule (MinimumLength) + assertThat(passwordValidator.errorMessage) + .isEqualTo(context.getString(R.string.fui_error_password_too_short, 8)) + } + + @Test + fun `validate returns true for password meeting all rules`() { + val rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + PasswordRule.RequireDigit, + PasswordRule.RequireSpecialCharacter + ) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123!") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + // ============================================================================================= + // Custom Rule Tests + // ============================================================================================= + + @Test + fun `validate works with custom regex rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("Password123") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } + + @Test + fun `validate returns custom error message for failing custom rule`() { + val customRule = PasswordRule.Custom( + // Valid (has upper, lower, digit, 8+ chars, only letters/digits) + regex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$"), + errorMessage = "Custom validation failed" + ) + val rules = listOf(customRule) + passwordValidator = PasswordValidator(stringProvider, rules) + + val isValid = passwordValidator.validate("weak") + + assertThat(isValid).isFalse() + assertThat(passwordValidator.hasError).isTrue() + assertThat(passwordValidator.errorMessage).isEqualTo("Custom validation failed") + } + + // ============================================================================================= + // Error State Management Tests + // ============================================================================================= + + @Test + fun `validate clears previous error when password becomes valid`() { + val rules = listOf(PasswordRule.MinimumLength(8)) + passwordValidator = PasswordValidator(stringProvider, rules) + + passwordValidator.validate("short") + assertThat(passwordValidator.hasError).isTrue() + + val isValid = passwordValidator.validate("longenough") + + assertThat(isValid).isTrue() + assertThat(passwordValidator.hasError).isFalse() + assertThat(passwordValidator.errorMessage).isEmpty() + } +} \ No newline at end of file