diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt index 589bc54d9..e743db3b2 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/HighLevelApiDemoActivity.kt @@ -66,6 +66,7 @@ import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState import com.firebase.ui.auth.util.EmailLinkConstants import com.firebase.ui.auth.util.displayIdentifier import com.firebase.ui.auth.util.getDisplayEmail +import com.firebaseui.android.demo.MainActivity import com.firebaseui.android.demo.R import com.firebaseui.android.demo.utils.EMAIL_LINK_ORIGIN_PARAM import com.google.firebase.auth.actionCodeSettings @@ -154,6 +155,8 @@ class HighLevelApiDemoActivity : ComponentActivity() { PasswordRule.RequireLowercase, PasswordRule.RequireUppercase, ), + // The Auth emulator does not serve the password policy. + isProjectPasswordPolicyEnabled = !MainActivity.USE_AUTH_EMULATOR, ) ) provider( diff --git a/auth/README.md b/auth/README.md index e2b99eb07..f94a782af 100644 --- a/auth/README.md +++ b/auth/README.md @@ -531,7 +531,11 @@ val emailProvider = AuthProvider.Email( PasswordRule.RequireLowercase, PasswordRule.RequireDigit, PasswordRule.RequireSpecialCharacter - ) + ), + + // Optional: Show the project's password policy as a sign-up checklist (default: false). + // Pass false (e.g. !useEmulator) with the Auth emulator, which has no password policy endpoint. + isProjectPasswordPolicyEnabled = true ) val configuration = authUIConfiguration { diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt index 8514e2bde..661603539 100644 --- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt @@ -24,6 +24,7 @@ import com.firebase.ui.auth.configuration.auth_provider.Provider import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle +import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository import com.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration import com.firebase.ui.auth.util.ProviderAvailability import com.google.firebase.Firebase @@ -101,6 +102,16 @@ class FirebaseAuthUI private constructor( @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) internal var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null + /** Replaces [passwordPolicyRepository]; read once, on its first use. */ + @Volatile + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + internal var testPasswordPolicyRepository: PasswordPolicyRepository? = null + + /** One per instance, so the fetched policy outlives the sign-up screen. */ + internal val passwordPolicyRepository: PasswordPolicyRepository by lazy { + testPasswordPolicyRepository ?: PasswordPolicyRepository() + } + /** * Checks whether a user is currently signed in. * diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt index e4c46dd4b..353e1f29b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt @@ -156,7 +156,41 @@ abstract class AuthProvider(open val providerId: String, open val providerName: * A list of custom password validation rules. */ val passwordValidationRules: List, + + /** + * Fetches the project's password policy and shows its requirements as a live checklist on + * sign-up. Hints only: the server still enforces the policy. Defaults to false when + * omitted, via the overload without this parameter. + * + * The Auth emulator does not serve the policy, so pass `!useEmulator` when you call + * `FirebaseAuth.useEmulator`. + */ + val isProjectPasswordPolicyEnabled: Boolean, ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) { + + /** + * Creates an email provider with [isProjectPasswordPolicyEnabled] set to false. Kept as a + * plain overload so existing Kotlin and Java callers still compile and link. + */ + constructor( + isDisplayNameRequired: Boolean = true, + isEmailLinkSignInEnabled: Boolean = false, + isEmailLinkForceSameDeviceEnabled: Boolean = true, + emailLinkActionCodeSettings: ActionCodeSettings?, + isNewAccountsAllowed: Boolean = true, + minimumPasswordLength: Int = 6, + passwordValidationRules: List, + ) : this( + isDisplayNameRequired = isDisplayNameRequired, + isEmailLinkSignInEnabled = isEmailLinkSignInEnabled, + isEmailLinkForceSameDeviceEnabled = isEmailLinkForceSameDeviceEnabled, + emailLinkActionCodeSettings = emailLinkActionCodeSettings, + isNewAccountsAllowed = isNewAccountsAllowed, + minimumPasswordLength = minimumPasswordLength, + passwordValidationRules = passwordValidationRules, + isProjectPasswordPolicyEnabled = false, + ) + companion object { const val SESSION_ID_LENGTH = 10 val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepository.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepository.kt index 02bac2c91..8ba79a546 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepository.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepository.kt @@ -22,6 +22,10 @@ import com.google.firebase.auth.FirebaseAuth import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -134,6 +138,14 @@ internal class PasswordPolicyRepository( private val cache = ConcurrentHashMap() private val locks = ConcurrentHashMap() + private val revisions = ConcurrentHashMap>() + + /** + * Incremented each time [refetchIfCached] stores a new policy for [auth]'s app and tenant, so + * readers of that key can re-read. Other keys are unaffected. + */ + fun revision(auth: FirebaseAuth): StateFlow = + revisionFor(Key(auth.app.name, auth.tenantId)).asStateFlow() /** Returns the cached policy for [auth], fetching it if absent or a failure has expired. */ suspend fun getPolicy(context: Context, auth: FirebaseAuth): PasswordPolicy? { @@ -156,12 +168,18 @@ internal class PasswordPolicyRepository( val key = Key(auth.app.name, auth.tenantId) return lockFor(key).withLock { val previous = (cache[key] as? Entry.Fetched)?.policy ?: return@withLock null - fetch(context, auth)?.also { cache[key] = Entry.Fetched(it) } ?: previous + fetch(context, auth)?.also { + cache[key] = Entry.Fetched(it) + revisionFor(key).update { revision -> revision + 1 } + } ?: previous } } private fun lockFor(key: Key): Mutex = locks.getOrPut(key) { Mutex() } + private fun revisionFor(key: Key): MutableStateFlow = + revisions.getOrPut(key) { MutableStateFlow(0) } + private suspend fun fetchAndStore( context: Context, auth: FirebaseAuth, diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 680e622f1..40c7f3af0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -18,6 +18,7 @@ import com.firebase.ui.auth.rememberAuthFlowScope import android.content.Context import android.util.Log import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -226,6 +227,20 @@ fun EmailAuthScreen( val isEmailLinkSignInOffered = configuration.isEmailLinkSignInOffered() val authFlowScope = rememberAuthFlowScope(authUI, configuration) + // Remembered: a new source per recomposition would restart the checklist's fetch. Keyed on the + // tenant the source is bound to; tenantId is not Compose state, so a tenant change is picked up + // on the screen's next recomposition. + val passwordPolicySource = remember( + authUI, + authUI.auth.tenantId, + provider.isProjectPasswordPolicyEnabled, + ) { + if (provider.isProjectPasswordPolicyEnabled) { + authUI.passwordPolicyRepository.sourceFor(authUI.auth) + } else { + null + } + } // Under a reauthentication request this is that request's phase, not the host's state. val authState by authFlowScope.state val isLoading = authState is AuthState.Loading || @@ -375,6 +390,13 @@ fun EmailAuthScreen( ) } catch (e: Exception) { onError(AuthException.from(e, stringProvider)) + // JS SDK parity: a rejection may mean the policy changed, so the checklist + // re-reads it. No-op unless a policy is cached. + if (passwordPolicySource != null && + e is AuthException.PasswordPolicyViolationException + ) { + authUI.passwordPolicyRepository.refetchIfCached(context, authUI.auth) + } } } }, @@ -398,14 +420,17 @@ fun EmailAuthScreen( onGoToEmailLinkSignIn = { goToMode(EmailAuthMode.EmailLinkSignIn) }, ) - if (content != null) { - content(state) - } else { - DefaultEmailAuthContent( - configuration = configuration, - state = state, - onCancel = onCancel - ) + // Null when the flag is off, so SignUpUI fetches nothing. + CompositionLocalProvider(LocalPasswordPolicySource provides passwordPolicySource) { + if (content != null) { + content(state) + } else { + DefaultEmailAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } } } 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 index 98971a607..4ca1073ad 100644 --- 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 @@ -39,10 +39,15 @@ 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 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf /** Supplies the project password policy to [SignUpUI]. Must return `null` rather than throw. */ internal fun interface PasswordPolicySource { suspend fun getPolicy(context: Context): PasswordPolicy? + + /** Emits on collection and again whenever [getPolicy] may return a newer policy. */ + val revision: Flow get() = flowOf(0) } /** @@ -52,8 +57,13 @@ internal fun interface PasswordPolicySource { 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) } +internal fun PasswordPolicyRepository.sourceFor(auth: FirebaseAuth): PasswordPolicySource { + val repository = this + return object : PasswordPolicySource { + override suspend fun getPolicy(context: Context) = repository.getPolicy(context, auth) + override val revision: Flow = repository.revision(auth) + } +} /** One checklist line. [symbols] is set only on the special character line. */ internal data class PasswordRequirement( 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 1dadf4413..58c260795 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 @@ -59,6 +59,7 @@ import com.firebase.ui.auth.ui.FirebaseAuthTestTags import com.firebase.ui.auth.ui.components.AuthTextField import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds +import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -106,9 +107,12 @@ fun SignUpUI( } val passwordPolicySource = LocalPasswordPolicySource.current // Hints only (never part of isFormValid). No source, no policy or OFF: nothing is shown. + // Re-read on each revision, so a refetch after a server rejection updates the checklist. val passwordPolicy by produceState(null, passwordPolicySource, context) { - value = passwordPolicySource?.getPolicy(context) - ?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE } + passwordPolicySource?.revision?.collectLatest { + value = passwordPolicySource.getPolicy(context) + ?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE } + } } val checklistRequirements = remember(passwordPolicy, password, stringProvider) { passwordPolicy?.let { passwordRequirements(it, password, stringProvider) } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt index e021b85af..020732c8b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AuthProviderTest.kt @@ -4,8 +4,10 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.R import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.authUIConfiguration import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.UserInfo @@ -72,6 +74,70 @@ class AuthProviderTest { provider.validate() } + @Test + fun `email provider project password policy is off by default`() { + val provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf() + ) + + assertThat(provider.isProjectPasswordPolicyEnabled).isFalse() + assertThat( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = listOf(), + isProjectPasswordPolicyEnabled = true, + ).isProjectPasswordPolicyEnabled + ).isTrue() + } + + // The exact JVM constructors callers compiled before isProjectPasswordPolicyEnabled link against. + @Test + fun `email provider keeps the pre-policy constructor for binary compatibility`() { + val rules = listOf(PasswordRule.RequireDigit) + val provider = AuthProvider.Email::class.java.getConstructor( + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + ActionCodeSettings::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + List::class.java, + ).newInstance(false, true, false, null, false, 9, rules) + + assertThat(provider.isDisplayNameRequired).isFalse() + assertThat(provider.isEmailLinkSignInEnabled).isTrue() + assertThat(provider.isEmailLinkForceSameDeviceEnabled).isFalse() + assertThat(provider.isNewAccountsAllowed).isFalse() + assertThat(provider.minimumPasswordLength).isEqualTo(9) + assertThat(provider.passwordValidationRules).isEqualTo(rules) + assertThat(provider.isProjectPasswordPolicyEnabled).isFalse() + } + + @Test + fun `email provider keeps the pre-policy default-arguments constructor`() { + // Bits 0, 1, 2, 4 and 5: every parameter that had a default was left out at the call site. + val defaultsMask = 0b110111 + val provider = AuthProvider.Email::class.java.getConstructor( + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + ActionCodeSettings::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + List::class.java, + Int::class.javaPrimitiveType, + Class.forName("kotlin.jvm.internal.DefaultConstructorMarker"), + ).newInstance(false, false, false, null, false, 0, emptyList(), defaultsMask, null) + + assertThat(provider.isDisplayNameRequired).isTrue() + assertThat(provider.isEmailLinkSignInEnabled).isFalse() + assertThat(provider.isEmailLinkForceSameDeviceEnabled).isTrue() + assertThat(provider.isNewAccountsAllowed).isTrue() + assertThat(provider.minimumPasswordLength).isEqualTo(6) + assertThat(provider.isProjectPasswordPolicyEnabled).isFalse() + } + @Test fun `email provider with email link enabled and valid action code settings should succeed`() { val actionCodeSettings = actionCodeSettings { diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepositoryTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepositoryTest.kt index e2c672f49..f2adf3e9e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepositoryTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/password_policy/PasswordPolicyRepositoryTest.kt @@ -343,6 +343,21 @@ class PasswordPolicyRepositoryTest { assertThat(http.requests).hasSize(1) } + @Test + fun `refetch bumps only its own key's revision`() = runTest { + val repo = repository() + val tenantA = auth(tenantId = "tenant-a") + val tenantB = auth(tenantId = "tenant-b") + repo.getPolicy(context, tenantA) + repo.getPolicy(context, tenantB) + + repo.refetchIfCached(context, tenantA) + + assertThat(repo.revision(tenantA).value).isEqualTo(1) + assertThat(repo.revision(tenantB).value).isEqualTo(0) + assertThat(repo.revision(auth(appName = "other")).value).isEqualTo(0) + } + private companion object { const val API_KEY = "AIzaFakeKeyForTests123" const val CERT = "0123456789ABCDEF0123456789ABCDEF01234567" diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenPasswordPolicyTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenPasswordPolicyTest.kt new file mode 100644 index 000000000..495b560ab --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreenPasswordPolicyTest.kt @@ -0,0 +1,406 @@ +/* + * 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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +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.test.core.app.ApplicationProvider +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +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.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.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthWeakPasswordException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [AuthProvider.Email.isProjectPasswordPolicyEnabled] wiring in [EmailAuthScreen]: the policy + * source it provides, the repository it shares, and the refetch after a server rejection. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class EmailAuthScreenPasswordPolicyTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var applicationContext: Context + private lateinit var stringProvider: AuthUIStringProvider + private lateinit var app: FirebaseApp + private lateinit var auth: FirebaseAuth + private lateinit var authUI: FirebaseAuthUI + + /** Served in order; the last one repeats. */ + private var responses = listOf(PasswordPolicyHttpClient.HttpResponse(200, policyJson(10))) + /** Untenanted requests only; tenant-routed ones count in [tenantRequests]. */ + private var requests = 0 + + /** Per-tenant responses, served in order to requests carrying that tenant; the last repeats. */ + private var tenantResponses = mapOf>() + private var tenantRequests = 0 + private val errors = mutableListOf() + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(applicationContext) + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { it.delete() } + app = FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + auth = mock(FirebaseAuth::class.java) + `when`(auth.app).thenReturn(app) + `when`(auth.tenantId).thenReturn(null) + authUI = FirebaseAuthUI.create(app, auth) + authUI.testPasswordPolicyRepository = fakeRepository() + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } + + private fun fakeRepository() = PasswordPolicyRepository( + httpClient = PasswordPolicyHttpClient { url, _ -> + val tenantResponses = tenantResponses.entries + .firstOrNull { (tenant, _) -> url.contains("tenantId=$tenant") }?.value + if (tenantResponses != null) { + tenantResponses[minOf(tenantRequests++, tenantResponses.lastIndex)] + } else { + responses[minOf(requests++, responses.lastIndex)] + } + }, + certificateHash = CertificateHashProvider { _, _ -> null }, + ioDispatcher = Dispatchers.Unconfined, + log = {}, + ) + + private fun provider(enabled: Boolean) = AuthProvider.Email( + isDisplayNameRequired = false, + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList(), + isProjectPasswordPolicyEnabled = enabled, + ) + + private fun setScreen( + enabled: Boolean, + content: (@Composable (EmailAuthContentState) -> Unit)? = null, + ) { + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider(enabled)) } + isCredentialManagerEnabled = false + } + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + EmailAuthScreen( + context = applicationContext, + configuration = configuration, + authUI = authUI, + mode = EmailAuthMode.SignUp, + onNavigateToMode = { _, _ -> }, + onSuccess = {}, + onError = { errors += it }, + onCancel = {}, + content = content, + ) + } + } + composeTestRule.waitForIdle() + } + + private val isChecklistLine = SemanticsMatcher.keyIsDefined(SemanticsProperties.ToggleableState) + + private fun checklistLine(label: String) = + composeTestRule.onNode(hasText(label) and isChecklistLine) + + private fun submit(password: String) { + composeTestRule.onNodeWithText(stringProvider.emailHint) + .performTextInput("test@example.com") + composeTestRule.onNodeWithText(stringProvider.passwordHint).performTextInput(password) + composeTestRule.onNodeWithText(stringProvider.confirmPasswordHint) + .performTextInput(password) + composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignUp.SIGN_UP_BUTTON) + .performScrollTo() + .performClick() + composeTestRule.waitForIdle() + } + + private fun rejectSignUp(exception: Exception) { + `when`(auth.createUserWithEmailAndPassword(anyString(), anyString())) + .thenReturn(Tasks.forException(exception)) + } + + private fun policyRejection(minLength: Int) = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "The given password is invalid.", + "PASSWORD_DOES_NOT_MEET_REQUIREMENTS:Missing password requirements: " + + "[Password must contain at least $minLength characters]", + ) + + // ============================================================================================= + // Source wiring + // ============================================================================================= + + @Test + fun `flag off provides no source and fetches nothing`() { + var source: PasswordPolicySource? = NOT_READ + setScreen(enabled = false) { source = LocalPasswordPolicySource.current } + + assertThat(source).isNull() + assertThat(requests).isEqualTo(0) + } + + @Test + fun `flag off shows no checklist on the default sign-up screen`() { + setScreen(enabled = false) + + composeTestRule.onAllNodes(isChecklistLine).assertCountEquals(0) + assertThat(requests).isEqualTo(0) + } + + @Test + fun `flag on fetches once and shows the policy checklist`() { + setScreen(enabled = true) + + assertThat(requests).isEqualTo(1) + checklistLine(stringProvider.passwordTooShort(10)).assertExists() + } + + @Test + fun `flag on provides one source across recompositions, backed by the shared repository`() { + val seen = mutableListOf() + var tick by mutableIntStateOf(0) + setScreen(enabled = true) { + // Read so a tick recomposes this slot. + check(tick >= 0) + seen += LocalPasswordPolicySource.current + } + tick++ + composeTestRule.waitForIdle() + + assertThat(seen.size).isAtLeast(2) + assertThat(seen.toSet()).hasSize(1) + assertThat(seen.first()).isNotNull() + // Reads through the instance's repository: served from its cache, no second request. + val policy = runBlocking { seen.first()!!.getPolicy(applicationContext) } + assertThat(policy?.minPasswordLength).isEqualTo(10) + assertThat(requests).isEqualTo(1) + } + + @Test + fun `one repository per FirebaseAuthUI instance`() { + val other = FirebaseAuthUI.create(app, auth) + + assertThat(authUI.passwordPolicyRepository).isSameInstanceAs(authUI.passwordPolicyRepository) + assertThat(other.passwordPolicyRepository).isNotSameInstanceAs(authUI.passwordPolicyRepository) + } + + @Test + fun `a recreated sign-up screen reuses the cached policy`() { + var visible by mutableStateOf(true) + val configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider(true)) } + isCredentialManagerEnabled = false + } + composeTestRule.setContent { + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + if (visible) { + EmailAuthScreen( + context = applicationContext, + configuration = configuration, + authUI = authUI, + mode = EmailAuthMode.SignUp, + onNavigateToMode = { _, _ -> }, + onSuccess = {}, + onError = {}, + onCancel = {}, + ) + } + } + } + composeTestRule.waitForIdle() + visible = false + composeTestRule.waitForIdle() + visible = true + composeTestRule.waitForIdle() + + assertThat(requests).isEqualTo(1) + checklistLine(stringProvider.passwordTooShort(10)).assertExists() + } + + // ============================================================================================= + // Refetch after PASSWORD_DOES_NOT_MEET_REQUIREMENTS + // ============================================================================================= + + @Test + fun `policy rejection refetches a cached policy and the checklist shows the new one`() { + responses = listOf( + PasswordPolicyHttpClient.HttpResponse(200, policyJson(10)), + PasswordPolicyHttpClient.HttpResponse(200, policyJson(12)), + ) + rejectSignUp(policyRejection(12)) + setScreen(enabled = true) + + submit("abcdefghij") + + assertThat(requests).isEqualTo(2) + checklistLine(stringProvider.passwordTooShort(12)).assertExists() + checklistLine(stringProvider.passwordTooShort(10)).assertDoesNotExist() + } + + @Test + fun `a tenant change while sign-up is shown shows and refetches that tenant's policy`() { + tenantResponses = mapOf( + "tenant-b" to listOf( + PasswordPolicyHttpClient.HttpResponse(200, policyJson(14)), + PasswordPolicyHttpClient.HttpResponse(200, policyJson(16)), + ) + ) + setScreen(enabled = true) + checklistLine(stringProvider.passwordTooShort(10)).assertExists() + + `when`(auth.tenantId).thenReturn("tenant-b") + // Not snapshot state: typing recomposes the screen so it reads the new tenant. + composeTestRule.onNodeWithText(stringProvider.emailHint).performTextInput("x") + composeTestRule.waitForIdle() + + assertThat(tenantRequests).isEqualTo(1) + checklistLine(stringProvider.passwordTooShort(14)).assertExists() + checklistLine(stringProvider.passwordTooShort(10)).assertDoesNotExist() + + rejectSignUp(policyRejection(16)) + submit("abcdefghijklmn") + + assertThat(tenantRequests).isEqualTo(2) + checklistLine(stringProvider.passwordTooShort(16)).assertExists() + checklistLine(stringProvider.passwordTooShort(14)).assertDoesNotExist() + } + + @Test + fun `other sign-up errors do not refetch`() { + rejectSignUp(FirebaseNetworkException("offline")) + setScreen(enabled = true) + + submit("abcdefghij") + + assertThat(errors).isNotEmpty() + assertThat(requests).isEqualTo(1) + } + + @Test + fun `policy rejection does not refetch when no policy is cached`() { + responses = listOf(PasswordPolicyHttpClient.HttpResponse(501, null)) + rejectSignUp(policyRejection(12)) + setScreen(enabled = true) + + submit("abcdefghij") + + assertThat(requests).isEqualTo(1) + composeTestRule.onAllNodes(isChecklistLine).assertCountEquals(0) + } + + @Test + fun `flag off never refetches after a policy rejection`() { + rejectSignUp(policyRejection(12)) + setScreen(enabled = false) + + submit("abcdefghij") + + assertThat(requests).isEqualTo(0) + } + + // ============================================================================================= + // Backstop: the server message matcher (CPRN-443) still applies when the fetch is unavailable + // ============================================================================================= + + @Test + fun `rejection maps to translated requirement messages when the policy fetch fails`() { + responses = listOf(PasswordPolicyHttpClient.HttpResponse(501, null)) + rejectSignUp(policyRejection(12)) + setScreen(enabled = true) + + submit("abcdefghij") + + val error = errors.first() + assertThat(error).isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + assertThat(error.message).isEqualTo(stringProvider.passwordTooShort(12)) + } + + private companion object { + val NOT_READ = PasswordPolicySource { null } + + fun policyJson(minLength: Int) = """ + { + "customStrengthOptions": {"minPasswordLength": $minLength}, + "enforcementState": "ENFORCE", + "schemaVersion": 1 + } + """ + } +}