Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,10 @@ 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.android.gms.tasks.Tasks
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
Expand All@@ -64,6 +67,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
Expand All@@ -90,6 +94,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
private lateinit var applicationContext: Context
private lateinit var stringProvider: DefaultAuthUIStringProvider
private lateinit var authUI: FirebaseAuthUI
private lateinit var mockAuth: FirebaseAuth
private lateinit var mockUser: FirebaseUser

private var pressBack: (() -> Unit)? = null
Expand All@@ -110,9 +115,9 @@ class FirebaseAuthScreenEmailRecoveryTest {
.setProjectId("fake-project-id")
.build()
)
val auth = mock(FirebaseAuth::class.java)
`when`(auth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, auth)
mockAuth = mock(FirebaseAuth::class.java)
`when`(mockAuth.app).thenReturn(app)
authUI = FirebaseAuthUI.create(app, mockAuth)
mockUser = mock(FirebaseUser::class.java)
`when`(mockUser.uid).thenReturn("recovery-user-uid")
}
Expand DownExpand Up@@ -751,6 +756,148 @@ class FirebaseAuthScreenEmailRecoveryTest {
assertThat(observed.none { it.contains("null") }).isTrue()
}

// =============================================================================================
// The recoveries that write something through for the step they move to
// =============================================================================================

/**
* Three recoveries do more than navigate: they hand the step they move to a value the failure
* carried — a credential to link, or the link to complete. The write and the move sit in the
* same lambda, so the step has to be *reached* for the write to be observable at all, and the
* order of the two is what makes it so.
*
* This one starts from the method picker rather than the sign-in step, which is where the
* failure actually comes from: an anonymous upgrade or a provider attempt discovers an account
* already holding the address, and nothing has been typed into an email form yet. The address
* therefore has to come off the failure.
*/
@Test
fun `an account that needs linking recovers to the sign-in step with the address the failure names`() {
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(mock(AuthCredential::class.java)))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithText(OTHER_EMAIL).assertIsDisplayed()
}

/**
* The point of the recovery, and the half a landing assertion cannot see: the credential the
* failure carried has to survive the move and be linked once the password sign-in it asked for
* succeeds. `EmailAuthScreen` reads it once, on the composition of the step it is given, so a
* recovery that navigated before writing — or that reused the step it was already on — would
* land the user on the right form and quietly drop the account they were linking.
*/
@Test
fun `signing in after an account-linking recovery links the credential the failure carried`() {
val credential = mock(AuthCredential::class.java)
val signedInUser = mock(FirebaseUser::class.java)
val signInResult = mock(AuthResult::class.java)
`when`(signInResult.user).thenReturn(signedInUser)
`when`(mockAuth.signInWithEmailAndPassword(OTHER_EMAIL, PASSWORD))
.thenReturn(Tasks.forResult(signInResult))
`when`(signedInUser.linkWithCredential(credential))
.thenReturn(Tasks.forResult(mock(AuthResult::class.java)))
start(configuration = emailAndPhoneConfiguration())

recoverFrom(accountLinkingRequired(credential))
signInWithPassword()

verify(signedInUser).linkWithCredential(credential)
}

/**
* The email link opened on a device that never asked for it: the session data naming the
* address is on the *other* device, so the only way forward is a form to type it into. The
* link itself has nowhere to live but the host, which hands it to the step it moves to.
*/
@Test
fun `a prompt for the email address recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

/**
* The same fallback the suggested-method recovery has: with email-link sign-in unconfigured
* there is no such step to offer, so the recovery lands on password sign-in rather than a form
* the provider cannot complete. Sign-up first, so the move is a real one rather than a
* recovery that was already where it wanted to be.
*/
@Test
fun `a prompt for the email address recovers to the sign-in step when email-link sign-in is disabled`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = false))
typeSignInEmail()
goToSignUp()

recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD).assertIsDisplayed()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertDoesNotExist()
}

/**
* The write-through, asserted the only way it is observable from outside: with a link in hand
* the step *completes* a sign-in rather than sending a fresh link, and only the completing call
* validates the link. So `isSignInWithEmailLink` having been asked about this exact link is
* both halves of the assertion — the link arrived, and it arrived where it changes what the
* button does.
*/
@Test
fun `the step a prompt recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(AuthException.EmailLinkPromptForEmailException(emailLink = EMAIL_LINK))

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

/**
* The third of the write-through recoveries, and a separate branch from the prompt above
* despite doing the same two things: this failure carries a `providerName` alongside its link,
* so the branch has something else to pick up by mistake.
*/
@Test
fun `a cross-device linking failure recovers to the email-link step`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()

recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.EMAIL_FIELD)
.assertIsDisplayed()
composeTestRule.onNodeWithText(TYPED_EMAIL).assertIsDisplayed()
}

@Test
fun `the step a cross-device recovery moved to signs in with the link the failure carried`() {
start(configuration = emailConfiguration(isEmailLinkSignInEnabled = true))
typeSignInEmail()
recoverFrom(
AuthException.EmailLinkCrossDeviceLinkingException(
providerName = "google.com",
emailLink = EMAIL_LINK,
)
)

sendEmailLink()

verify(mockAuth).isSignInWithEmailLink(EMAIL_LINK)
}

// =============================================================================================
// Harness
// =============================================================================================
Expand DownExpand Up@@ -828,6 +975,13 @@ class FirebaseAuthScreenEmailRecoveryTest {
"${initialState.authRoute()?.let { it::class.simpleName }}->" +
"${targetState.authRoute()?.let { it::class.simpleName }}"

private fun accountLinkingRequired(credential: AuthCredential) =
AuthException.AccountLinkingRequiredException(
message = "an account already exists with this address",
email = OTHER_EMAIL,
credential = credential,
)

private fun differentSignInMethodRequired() =
AuthException.DifferentSignInMethodRequiredException(
message = "use the email link",
Expand DownExpand Up@@ -879,6 +1033,22 @@ class FirebaseAuthScreenEmailRecoveryTest {
composeTestRule.waitForIdle()
}

/** Fills in the password the sign-in step is missing and submits it. */
private fun signInWithPassword() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.PASSWORD_FIELD)
.performTextInput(PASSWORD)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_IN_BUTTON).performClick()
composeTestRule.waitForIdle()
}

/** Submits the email-link step the address it was opened on. */
private fun sendEmailLink() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.EmailLink.SEND_LINK_BUTTON)
.performClick()
composeTestRule.waitForIdle()
}

private fun goToSignUp() {
composeTestRule.onNodeWithTag(FirebaseAuthTestTags.SignIn.SIGN_UP_BUTTON).performClick()
composeTestRule.waitForIdle()
Expand DownExpand Up@@ -909,5 +1079,7 @@ class FirebaseAuthScreenEmailRecoveryTest {
const val TYPED_EMAIL = "user+tag@example.com"
const val OTHER_EMAIL = "someone.else@example.com"
const val AUTHENTICATED_TAG = "recovery_test_authenticated"
const val PASSWORD = "correct-horse-battery"
const val EMAIL_LINK = "https://example.com/finish?oobCode=abc&mode=signIn"
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.navigation3.runtime.entryProvider
Expand All@@ -42,6 +44,7 @@ import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
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.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.phone.rememberPhoneAuthFlowState
import com.google.common.truth.Truth.assertThat
Expand DownExpand Up@@ -170,6 +173,64 @@ class ReauthSurfaceGateTest {
assertThat(handedOut.filter { it.substringBefore('/') != it.substringAfter('/') }).isEmpty()
}

/**
* The custom-slot presentation gate is on the **step**, not on the slot. `reauthContent`
* replaces the library's method picker, so that step is composed bare — no sheet, no scrim,
* the host draws its own chrome:
*/
@Test
fun `a custom slot composes the method picker bare`() {
setContent(step = AuthRoute.MethodPicker)

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(0)
}

/**
* — but the sub-flows that step hands off to are the library's own screens, and they keep the
* library's sheet around them. That is what the slot has always done, so the sheet is not
* optional chrome here: a host that styled its picker to sit flush against the flow underneath
* gets a sheet the moment the user picks email, and the email form would have no surface of its
* own without one.
*
* Asserted through the sheet's own node rather than beside it: the step being present and a
* sheet being present are two facts, and only ancestry says the step is *in* it.
*/
@Test
fun `a custom slot's email step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Email.SignIn())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.SignIn.EMAIL_FIELD) and hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** The other sub-flow the slot hands off to, on a user linked to phone rather than password. */
@Test
fun `a custom slot's phone step is still shown inside the library sheet`() {
setContent(step = AuthRoute.Phone.EnterPhoneNumber, user = phoneUser())

composeTestRule.onAllNodes(SHEET, useUnmergedTree = true).assertCountEquals(1)
composeTestRule.onNode(
hasTestTag(FirebaseAuthTestTags.PhoneNumber.PHONE_NUMBER_FIELD) and
hasAnyAncestor(SHEET),
useUnmergedTree = true,
).assertExists()
}

/** An armed request whose entry sits at [step], with a custom `reauthContent` installed. */
private fun setContent(step: AuthRoute.Destination, user: FirebaseUser = passwordUser()) {
composeTestRule.setContent {
Harness(
reauthState = AuthState.Reauthentication.Required(user),
useSlot = true,
step = step,
)
}
composeTestRule.waitForIdle()
}

private fun setContent(armed: Boolean) {
val state = if (armed) AuthState.Reauthentication.Required(passwordUser()) else null
composeTestRule.setContent { Harness(state) }
Expand All@@ -191,13 +252,16 @@ class ReauthSurfaceGateTest {
* @param useSlot Installs a `reauthContent` slot that records what the entry hands it. The
* slot is the only path to the entry's `updateReauthentication` writes, so nothing recorded
* means no write was offered.
* @param step The step the reauthentication entry sits at. Defaults to the method picker, the
* step every request starts on.
*/
@Composable
private fun Harness(
reauthState: AuthState.Reauthentication?,
entryRequestId: String = reauthState?.requestId ?: "unarmed-request",
armedRequest: MutableState<AuthState.Reauthentication?>? = null,
useSlot: Boolean = false,
step: AuthRoute.Destination = AuthRoute.MethodPicker,
) {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val configuration = remember {
Expand All@@ -210,6 +274,15 @@ class ReauthSurfaceGateTest {
passwordValidationRules = emptyList(),
)
)
// Narrowed to what the user is actually linked to before it reaches the entry,
// so configuring both leaves a password user's picker exactly as it was.
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = null,
)
)
}
}
}
Expand All@@ -218,8 +291,8 @@ class ReauthSurfaceGateTest {
AuthRoute.MethodPicker,
AuthRoute.Reauth(
requestId = entryRequestId,
userUid = "uid-password",
step = AuthRoute.MethodPicker,
userUid = reauthState?.userUid ?: "uid-password",
step = step,
),
)
if (armedRequest != null) {
Expand DownExpand Up@@ -289,6 +362,15 @@ class ReauthSurfaceGateTest {
}
}

private fun phoneUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("phone")
val user = mock(FirebaseUser::class.java)
`when`(user.uid).thenReturn("uid-phone")
`when`(user.providerData).thenReturn(listOf(providerInfo))
return user
}

private fun passwordUser(): FirebaseUser {
val providerInfo = mock(UserInfo::class.java)
`when`(providerInfo.providerId).thenReturn("password")
Expand Down