diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt new file mode 100644 index 000000000..3bf46c50a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.mfa + +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorAssertion +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import kotlinx.coroutines.tasks.await + +/** + * Handler for SMS multi-factor authentication enrollment. + * + * This class manages the complete SMS enrollment flow, including: + * - Sending SMS verification codes to phone numbers + * - Resending codes with timer support + * - Verifying SMS codes entered by users + * - Finalizing enrollment with Firebase Authentication + * + * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure + * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. + * + * **Usage:** + * ```kotlin + * val handler = SmsEnrollmentHandler(auth, user) + * + * // Step 1: Send verification code + * val session = handler.sendVerificationCode("+1234567890") + * + * // Step 2: Display masked phone number and wait for user input + * val masked = session.getMaskedPhoneNumber() + * + * // Step 3: If needed, resend code after timer expires + * val newSession = handler.resendVerificationCode(session) + * + * // Step 4: Verify the code entered by the user + * val verificationCode = "123456" // From user input + * handler.enrollWithVerificationCode(session, verificationCode, "My Phone") + * ``` + * + * @property auth The [FirebaseAuth] instance + * @property user The [FirebaseUser] to enroll in SMS MFA + * + * @since 10.0.0 + * @see TotpEnrollmentHandler + * @see AuthProvider.Phone.verifyPhoneNumberAwait + */ +class SmsEnrollmentHandler( + private val auth: FirebaseAuth, + private val user: FirebaseUser +) { + private val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + smsCodeLength = SMS_CODE_LENGTH, + timeout = VERIFICATION_TIMEOUT_SECONDS, + isInstantVerificationEnabled = true + ) + /** + * Sends an SMS verification code to the specified phone number. + * + * This method initiates the SMS enrollment process by sending a verification code + * to the provided phone number. The code will be sent via SMS and should be + * displayed to the user for entry. + * + * **Important:** The user must re-authenticate before calling this method if their + * session is not recent. Use [FirebaseUser.reauthenticate] if needed. + * + * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890") + * @return An [SmsEnrollmentSession] containing the verification ID and metadata + * @throws Exception if the user needs to re-authenticate, phone number is invalid, + * or SMS sending fails + * + * @see resendVerificationCode + * @see SmsEnrollmentSession.getMaskedPhoneNumber + */ + suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession { + require(isValidPhoneNumber(phoneNumber)) { + "Phone number must be in E.164 format (e.g., +1234567890)" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = null + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = phoneNumber, + forceResendingToken = null, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Resends the SMS verification code to the phone number. + * + * This method uses the force resending token from the original session to + * explicitly request a new SMS code. This should only be called after the + * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits. + * + * @param session The original [SmsEnrollmentSession] from [sendVerificationCode] + * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp + * @throws Exception if resending fails or if the session doesn't have a resend token + * + * @see sendVerificationCode + */ + suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession { + require(session.forceResendingToken != null) { + "Cannot resend code without a force resending token" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + phoneNumber = session.phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = session.forceResendingToken + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = session.phoneNumber, + forceResendingToken = session.forceResendingToken, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = session.phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Verifies an SMS code and completes the enrollment process. + * + * This method creates a multi-factor assertion using the provided session and + * verification code, then enrolls the user in SMS MFA with Firebase Authentication. + * + * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode] + * @param verificationCode The 6-digit code from the SMS message + * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone") + * @throws Exception if the verification code is invalid or if enrollment fails + * + * @see sendVerificationCode + * @see resendVerificationCode + */ + suspend fun enrollWithVerificationCode( + session: SmsEnrollmentSession, + verificationCode: String, + displayName: String? = null + ) { + require(isValidCodeFormat(verificationCode)) { + "Verification code must be 6 digits" + } + + val credential = session.autoVerifiedCredential + ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode) + + val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential) + user.multiFactor.enroll(multiFactorAssertion, displayName).await() + } + + /** + * Validates that a verification code has the correct format for SMS. + * + * This method performs basic client-side validation to ensure the code: + * - Is not null or empty + * - Contains only digits + * - Has exactly 6 digits (the standard SMS code length) + * + * **Note:** This does not verify the code against the server. Use + * [enrollWithVerificationCode] to perform actual verification with Firebase. + * + * @param code The verification code to validate + * @return `true` if the code has a valid format, `false` otherwise + */ + fun isValidCodeFormat(code: String): Boolean { + return code.isNotBlank() && + code.length == SMS_CODE_LENGTH && + code.all { it.isDigit() } + } + + /** + * Validates that a phone number is in the correct E.164 format. + * + * E.164 format requirements: + * - Starts with "+" + * - Followed by 1-15 digits + * - No spaces, hyphens, or other characters + * - Minimum 4 digits total (country code + subscriber number) + * + * Examples of valid numbers: + * - +1234567890 (US) + * - +447911123456 (UK) + * - +33612345678 (France) + * + * @param phoneNumber The phone number to validate + * @return `true` if the phone number is in E.164 format, `false` otherwise + */ + fun isValidPhoneNumber(phoneNumber: String): Boolean { + return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$")) + } + + companion object { + /** + * The standard length for SMS verification codes. + */ + const val SMS_CODE_LENGTH = 6 + + /** + * The verification timeout in seconds for phone authentication. + * This is how long Firebase will wait for auto-verification before + * falling back to manual code entry. + */ + const val VERIFICATION_TIMEOUT_SECONDS = 60L + + /** + * The recommended delay in seconds before allowing code resend. + * This prevents users from spamming the resend functionality and + * respects carrier rate limits. + */ + const val RESEND_DELAY_SECONDS = 30 + + /** + * The Firebase factor ID for SMS multi-factor authentication. + */ + const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID + } +} + +/** + * Represents an active SMS enrollment session with verification state. + * + * This class holds all the information needed to complete an SMS enrollment, + * including the verification ID, phone number, and resend token. + * + * @property verificationId The verification ID from Firebase + * @property phoneNumber The phone number being verified in E.164 format + * @property forceResendingToken Optional token for resending the SMS code + * @property sentAt Timestamp in milliseconds when the code was sent + * @property autoVerifiedCredential Optional credential if auto-verification succeeded + * + * @since 10.0.0 + */ +data class SmsEnrollmentSession( + val verificationId: String, + val phoneNumber: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + val sentAt: Long, + val autoVerifiedCredential: PhoneAuthCredential? = null +) { + /** + * Returns a masked version of the phone number for display purposes. + * + * Masks the middle digits of the phone number while keeping the country code + * and last few digits visible for user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" + * - "+447911123456" → "+44•••••••456" + * + * @return The masked phone number string + */ + fun getMaskedPhoneNumber(): String { + return maskPhoneNumber(phoneNumber) + } + + /** + * Checks if the resend delay has elapsed since the code was sent. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return `true` if enough time has passed to allow resending + */ + fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return elapsed >= delaySec + } + + /** + * Returns the remaining seconds until resend is allowed. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return The number of seconds remaining, or 0 if resend is already allowed + */ + fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return maxOf(0, delaySec - elapsed.toInt()) + } +} + +/** + * Masks the middle digits of a phone number for privacy. + * + * The function keeps the country code (first 1-3 characters after +) and + * the last 2-4 digits visible, masking everything in between with bullets. + * Longer phone numbers show more last digits for better user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits) + * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits) + * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits) + * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits) + * + * @param phoneNumber The phone number to mask in E.164 format + * @return The masked phone number string + */ +fun maskPhoneNumber(phoneNumber: String): String { + if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { + return phoneNumber + } + + // Determine country code length (typically 1-3 digits after +) + val digitsOnly = phoneNumber.substring(1) // Remove + + val countryCodeLength = when { + digitsOnly.length > 10 -> 2 // Likely 2-digit country code + digitsOnly[0] == '1' -> 1 // North America + else -> 2 // Most other countries + } + + val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + + // Keep last 3-4 digits visible, with longer numbers showing more + val lastDigitsCount = when { + phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits + phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits + else -> 2 // Short numbers show 2 digits + } + val lastDigits = phoneNumber.takeLast(lastDigitsCount) + val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount + + return "$countryCode${"•".repeat(maskedLength)}$lastDigits" +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt new file mode 100644 index 000000000..579b6f0f6 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandlerTest.kt @@ -0,0 +1,401 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.mfa + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactor +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [SmsEnrollmentHandler]. + * + * Note: Full integration tests for SMS sending and enrollment require + * mocking static Firebase methods and Android components, which is complex. + * These tests focus on validation logic, constants, and utility functions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SmsEnrollmentHandlerTest { + + @Mock + private lateinit var mockAuth: FirebaseAuth + @Mock + private lateinit var mockUser: FirebaseUser + @Mock + private lateinit var mockMultiFactor: MultiFactor + private lateinit var handler: SmsEnrollmentHandler + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + `when`(mockUser.multiFactor).thenReturn(mockMultiFactor) + handler = SmsEnrollmentHandler(mockAuth, mockUser) + } + + // isValidCodeFormat tests + + @Test + fun `isValidCodeFormat returns true for valid 6-digit codes`() { + assertTrue(handler.isValidCodeFormat("123456")) + assertTrue(handler.isValidCodeFormat("000000")) + assertTrue(handler.isValidCodeFormat("999999")) + assertTrue(handler.isValidCodeFormat("654321")) + } + + @Test + fun `isValidCodeFormat returns false for empty or blank codes`() { + assertFalse(handler.isValidCodeFormat("")) + assertFalse(handler.isValidCodeFormat(" ")) + assertFalse(handler.isValidCodeFormat(" ")) + } + + @Test + fun `isValidCodeFormat returns false for codes with wrong length`() { + assertFalse(handler.isValidCodeFormat("12345")) // Too short + assertFalse(handler.isValidCodeFormat("1234567")) // Too long + assertFalse(handler.isValidCodeFormat("1")) // Way too short + assertFalse(handler.isValidCodeFormat("12345678901234567890")) // Way too long + } + + @Test + fun `isValidCodeFormat returns false for codes with non-digit characters`() { + assertFalse(handler.isValidCodeFormat("12345a")) // Contains letter + assertFalse(handler.isValidCodeFormat("12 34 56")) // Contains spaces + assertFalse(handler.isValidCodeFormat("abc123")) // Contains letters + assertFalse(handler.isValidCodeFormat("12-345")) // Contains dash + assertFalse(handler.isValidCodeFormat("12.345")) // Contains dot + assertFalse(handler.isValidCodeFormat("123!56")) // Contains special char + } + + // isValidPhoneNumber tests + + @Test + fun `isValidPhoneNumber returns true for valid E164 phone numbers`() { + assertTrue(handler.isValidPhoneNumber("+1234567890")) // US + assertTrue(handler.isValidPhoneNumber("+447911123456")) // UK + assertTrue(handler.isValidPhoneNumber("+33612345678")) // France + assertTrue(handler.isValidPhoneNumber("+861234567890")) // China + assertTrue(handler.isValidPhoneNumber("+5511987654321")) // Brazil + } + + @Test + fun `isValidPhoneNumber returns false for numbers without plus sign`() { + assertFalse(handler.isValidPhoneNumber("1234567890")) + assertFalse(handler.isValidPhoneNumber("447911123456")) + } + + @Test + fun `isValidPhoneNumber returns false for numbers starting with zero after plus`() { + assertFalse(handler.isValidPhoneNumber("+0234567890")) + assertFalse(handler.isValidPhoneNumber("+0447911123456")) + } + + @Test + fun `isValidPhoneNumber returns false for numbers that are too short`() { + assertFalse(handler.isValidPhoneNumber("+1")) // Too short + assertFalse(handler.isValidPhoneNumber("+12")) // Still too short + } + + @Test + fun `isValidPhoneNumber returns false for numbers that are too long`() { + assertFalse(handler.isValidPhoneNumber("+12345678901234567")) // More than 15 digits + } + + @Test + fun `isValidPhoneNumber returns false for numbers with non-digit characters`() { + assertFalse(handler.isValidPhoneNumber("+1 234 567 890")) // Spaces + assertFalse(handler.isValidPhoneNumber("+1-234-567-890")) // Dashes + assertFalse(handler.isValidPhoneNumber("+1(234)567890")) // Parentheses + assertFalse(handler.isValidPhoneNumber("+1.234.567.890")) // Dots + } + + @Test + fun `isValidPhoneNumber returns false for empty or blank numbers`() { + assertFalse(handler.isValidPhoneNumber("")) + assertFalse(handler.isValidPhoneNumber(" ")) + } + + // Constants tests + + @Test + fun `constants have expected values`() { + assertEquals(6, SmsEnrollmentHandler.SMS_CODE_LENGTH) + assertEquals(60L, SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS) + assertEquals(30, SmsEnrollmentHandler.RESEND_DELAY_SECONDS) + assertEquals(PhoneMultiFactorGenerator.FACTOR_ID, SmsEnrollmentHandler.FACTOR_ID) + } + + @Test + fun `handler is created with correct auth and user references`() { + // Verify handler can be instantiated + val newHandler = SmsEnrollmentHandler(mockAuth, mockUser) + // Basic smoke test - if we get here, construction succeeded + assertTrue(newHandler.isValidCodeFormat("123456")) + } + + // maskPhoneNumber tests + + @Test + fun `maskPhoneNumber masks US phone numbers correctly`() { + val masked = maskPhoneNumber("+1234567890") + assertTrue(masked.startsWith("+1")) + assertTrue(masked.endsWith("890")) + assertTrue(masked.contains("•")) + assertEquals("+1••••••890", masked) + } + + @Test + fun `maskPhoneNumber masks UK phone numbers correctly`() { + val masked = maskPhoneNumber("+447911123456") + assertTrue(masked.startsWith("+44")) + assertTrue(masked.endsWith("456")) + assertTrue(masked.contains("•")) + // UK number: 13 chars, shows last 3 digits + assertEquals("+44•••••••456", masked) + } + + @Test + fun `maskPhoneNumber masks French phone numbers correctly`() { + val masked = maskPhoneNumber("+33612345678") + assertTrue(masked.startsWith("+33")) + assertTrue(masked.endsWith("678")) + assertTrue(masked.contains("•")) + // French number: 12 chars, shows last 3 digits + assertEquals("+33••••••678", masked) + } + + @Test + fun `maskPhoneNumber handles short phone numbers`() { + val short = "+1234567" + val masked = maskPhoneNumber(short) + assertTrue(masked.startsWith("+1")) + assertTrue(masked.contains("•")) + } + + @Test + fun `maskPhoneNumber returns original for invalid numbers`() { + assertEquals("1234567890", maskPhoneNumber("1234567890")) // No + + assertEquals("abc", maskPhoneNumber("abc")) // Not a number + assertEquals("+123", maskPhoneNumber("+123")) // Too short + } + + @Test + fun `maskPhoneNumber masks different country codes correctly`() { + // Single-digit country code (US) + val us = maskPhoneNumber("+1234567890") + assertTrue(us.startsWith("+1")) + + // Two-digit country code (UK) + val uk = maskPhoneNumber("+447911123456") + assertTrue(uk.startsWith("+44")) + + // Three-digit country code (less common, but handled) + val threeDigit = maskPhoneNumber("+8861234567890") + assertTrue(threeDigit.startsWith("+88")) + } +} + +/** + * Unit tests for [SmsEnrollmentSession]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SmsEnrollmentSessionTest { + + @Mock + private lateinit var mockForceResendingToken: PhoneAuthProvider.ForceResendingToken + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `session holds all properties correctly`() { + val session = SmsEnrollmentSession( + verificationId = "test-id-123", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = 1000000L + ) + + assertEquals("test-id-123", session.verificationId) + assertEquals("+1234567890", session.phoneNumber) + assertEquals(mockForceResendingToken, session.forceResendingToken) + assertEquals(1000000L, session.sentAt) + } + + @Test + fun `getMaskedPhoneNumber returns masked version`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = System.currentTimeMillis() + ) + + val masked = session.getMaskedPhoneNumber() + // 11 char number shows last 3 digits + assertEquals("+1••••••890", masked) + } + + @Test + fun `canResend returns false immediately after sending`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() + ) + + assertFalse(session.canResend()) + } + + @Test + fun `canResend returns true after delay has passed`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 31_000 // 31 seconds ago + ) + + assertTrue(session.canResend()) + } + + @Test + fun `canResend works with custom delay`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 5_000 // 5 seconds ago + ) + + assertFalse(session.canResend(10)) // 10 second delay + assertTrue(session.canResend(4)) // 4 second delay + } + + @Test + fun `getRemainingResendSeconds returns correct value`() { + val now = System.currentTimeMillis() + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = now - 10_000 // 10 seconds ago + ) + + val remaining = session.getRemainingResendSeconds(30) + // Should be around 20 seconds (30 - 10) + assertTrue(remaining in 19..21) + } + + @Test + fun `getRemainingResendSeconds returns 0 when resend is allowed`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = System.currentTimeMillis() - 35_000 // 35 seconds ago + ) + + assertEquals(0, session.getRemainingResendSeconds(30)) + } + + @Test + fun `getRemainingResendSeconds works with custom delay`() { + val now = System.currentTimeMillis() + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = mockForceResendingToken, + sentAt = now - 3_000 // 3 seconds ago + ) + + val remaining = session.getRemainingResendSeconds(10) + // Should be around 7 seconds (10 - 3) + assertTrue(remaining in 6..8) + } + + @Test + fun `session without forceResendingToken can be created`() { + val session = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = System.currentTimeMillis() + ) + + assertEquals("test-id", session.verificationId) + assertEquals(null, session.forceResendingToken) + } + + @Test + fun `session equality works correctly`() { + val session1 = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val session2 = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val session3 = SmsEnrollmentSession( + verificationId = "different-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + assertEquals(session1, session2) + assertFalse(session1 == session3) + } + + @Test + fun `session copy works correctly`() { + val original = SmsEnrollmentSession( + verificationId = "test-id", + phoneNumber = "+1234567890", + forceResendingToken = null, + sentAt = 1000000L + ) + + val copied = original.copy(verificationId = "new-id") + + assertEquals("new-id", copied.verificationId) + assertEquals("+1234567890", copied.phoneNumber) + assertEquals("test-id", original.verificationId) // Original unchanged + } +}