From e2cd3867dd128b479e8e07daf55316e37772cba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Thu, 15 Aug 2024 14:28:35 +0100 Subject: [PATCH] [PM-6702] 3# Open app from App Link to CompleteRegistration (#3619) --- app/src/main/AndroidManifest.xml | 11 ++ .../java/com/x8bit/bitwarden/MainViewModel.kt | 15 ++ .../util/CompleteRegistrationDataUtils.kt | 24 ++++ .../manager/model/CompleteRegistrationData.kt | 18 +++ .../manager/model/SpecialCircumstance.kt | 9 ++ .../util/SpecialCircumstanceExtensions.kt | 2 + .../platform/feature/rootnav/RootNavScreen.kt | 11 +- .../feature/rootnav/RootNavViewModel.kt | 29 +++- .../com/x8bit/bitwarden/MainViewModelTest.kt | 59 ++++++++ .../util/CompleteRegistrationDataUtilsTest.kt | 121 ++++++++++++++++ .../feature/rootnav/RootNavViewModelTest.kt | 133 ++++++++++++++++++ 11 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93d6aabdc4..048625232c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,17 @@ + + + + + + + + + + + diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 1c31b93d37..26e4254cc5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull @@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize +import java.time.Clock import javax.inject.Inject private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance" @@ -53,6 +55,7 @@ class MainViewModel @Inject constructor( private val vaultRepository: VaultRepository, private val authRepository: AuthRepository, private val savedStateHandle: SavedStateHandle, + private val clock: Clock, ) : BaseViewModel( initialState = MainState( theme = settingsRepository.appTheme, @@ -188,6 +191,7 @@ class MainViewModel @Inject constructor( val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut val hasVaultShortcut = intent.isMyVaultShortcut val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull() + val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull() val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull() val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull() when { @@ -201,6 +205,17 @@ class MainViewModel @Inject constructor( ) } + completeRegistrationData != null -> { + if (authRepository.activeUserId != null) { + authRepository.hasPendingAccountAddition = true + } + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CompleteRegistration( + completeRegistrationData = completeRegistrationData, + timestamp = clock.millis(), + ) + } + autofillSaveItem != null -> { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSave( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt new file mode 100644 index 0000000000..e9ddce9f05 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtils.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.auth.util + +import android.content.Intent +import android.net.Uri +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData + +/** + * Checks if the given [Intent] contains data to complete registration. + * The [CompleteRegistrationData] will be returned when present. + */ +fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? { + val sanitizedUriString = data.toString().replace("/#/", "/") + val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null + uri.host ?: return null + if (uri.path != "/finish-signup") return null + val email = uri.getQueryParameter("email") ?: return null + val verificationToken = uri.getQueryParameter("token") ?: return null + val fromEmail = uri.getBooleanQueryParameter("fromEmail", true) + return CompleteRegistrationData( + email = email, + verificationToken = verificationToken, + fromEmail = fromEmail, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt new file mode 100644 index 0000000000..72134216b7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/CompleteRegistrationData.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.platform.manager.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Required data to complete ongoing registration process. + * + * @property email The email of the user creating the account. + * @property verificationToken The token required to finish the registration process. + * @property fromEmail indicates that this information came from an email AppLink. + */ +@Parcelize +data class CompleteRegistrationData( + val email: String, + val verificationToken: String, + val fromEmail: Boolean, +) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 4e2d386b74..915c58b199 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -50,6 +50,15 @@ sealed class SpecialCircumstance : Parcelable { val shouldFinishWhenComplete: Boolean, ) : SpecialCircumstance() + /** + * The app was launched via AppLink in order to allow the user complete an ongoing registration. + */ + @Parcelize + data class CompleteRegistration( + val completeRegistrationData: CompleteRegistrationData, + val timestamp: Long, + ) : SpecialCircumstance() + /** * The app was launched via the credential manager framework in order to allow the user to * manually save a passkey to their vault. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 97fa9abcc1..ccaaacd933 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -19,6 +19,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? = SpecialCircumstance.GeneratorShortcut -> null SpecialCircumstance.VaultShortcut -> null is SpecialCircumstance.Fido2Save -> null + is SpecialCircumstance.CompleteRegistration -> null is SpecialCircumstance.Fido2Assertion -> null is SpecialCircumstance.Fido2GetCredentials -> null } @@ -35,6 +36,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData? SpecialCircumstance.GeneratorShortcut -> null SpecialCircumstance.VaultShortcut -> null is SpecialCircumstance.Fido2Save -> null + is SpecialCircumstance.CompleteRegistration -> null is SpecialCircumstance.Fido2Assertion -> null is SpecialCircumstance.Fido2GetCredentials -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 312d3dfec0..a1b76b7f5e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -85,8 +85,10 @@ fun RootNavScreen( } val targetRoute = when (state) { - RootNavState.Auth -> AUTH_GRAPH_ROUTE - RootNavState.AuthWithWelcome -> AUTH_GRAPH_ROUTE + RootNavState.Auth, + is RootNavState.CompleteOngoingRegistration, + RootNavState.AuthWithWelcome, + -> AUTH_GRAPH_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.SetPassword -> SET_PASSWORD_ROUTE RootNavState.Splash -> SPLASH_ROUTE @@ -189,6 +191,11 @@ fun RootNavScreen( navOptions = rootNavOptions, ) } + + is RootNavState.CompleteOngoingRegistration -> { + navController.navigateToAuthGraph(rootNavOptions) + // TODO PR-3622: add navigation to complete registration + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index ca1a6821d0..4507b6c4f0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -57,6 +57,7 @@ class RootNavViewModel @Inject constructor( action: RootNavAction.Internal.UserStateUpdateReceive, ) { val userState = action.userState + val specialCircumstance = action.specialCircumstance val updatedRootNavState = when { userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false && !userState.activeAccount.isVaultUnlocked && @@ -66,6 +67,15 @@ class RootNavViewModel @Inject constructor( userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword + specialCircumstance is SpecialCircumstance.CompleteRegistration -> { + RootNavState.CompleteOngoingRegistration( + email = specialCircumstance.completeRegistrationData.email, + verificationToken = specialCircumstance.completeRegistrationData.verificationToken, + fromEmail = specialCircumstance.completeRegistrationData.fromEmail, + timestamp = specialCircumstance.timestamp, + ) + } + userState == null || !userState.activeAccount.isLoggedIn || userState.hasPendingAccountAddition -> { @@ -77,7 +87,7 @@ class RootNavViewModel @Inject constructor( } userState.activeAccount.isVaultUnlocked -> { - when (val specialCircumstance = action.specialCircumstance) { + when (specialCircumstance) { is SpecialCircumstance.AutofillSave -> { RootNavState.VaultUnlockedForAutofillSave( autofillSaveItem = specialCircumstance.autofillSaveItem, @@ -122,6 +132,12 @@ class RootNavViewModel @Inject constructor( SpecialCircumstance.VaultShortcut, null, -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) + + is SpecialCircumstance.CompleteRegistration -> { + throw IllegalStateException( + "Special circumstance should have been already handled.", + ) + } } } @@ -242,6 +258,17 @@ sealed class RootNavState : Parcelable { @Parcelize data object VaultUnlockedForNewSend : RootNavState() + /** + * App should show the screen to complete an ongoing registration process. + */ + @Parcelize + data class CompleteOngoingRegistration( + val email: String, + val verificationToken: String, + val fromEmail: Boolean, + val timestamp: Long, + ) : RootNavState() + /** * App should show the auth confirmation screen for an unlocked user. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index eae318fae8..58e037cd77 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -8,6 +8,7 @@ import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest @@ -28,6 +29,7 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -55,7 +57,11 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +@Suppress("LargeClass") class MainViewModelTest : BaseViewModelTest() { private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl() @@ -96,6 +102,7 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getPasswordlessRequestDataIntentOrNull, Intent::getAutofillSaveItemOrNull, Intent::getAutofillSelectionDataOrNull, + Intent::getCompleteRegistrationDataIntentOrNull, Intent::getFido2CredentialRequestOrNull, ) mockkStatic( @@ -110,6 +117,7 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getPasswordlessRequestDataIntentOrNull, Intent::getAutofillSaveItemOrNull, Intent::getAutofillSelectionDataOrNull, + Intent::getCompleteRegistrationDataIntentOrNull, ) unmockkStatic( Intent::isMyVaultShortcut, @@ -261,6 +269,7 @@ class MainViewModelTest : BaseViewModelTest() { every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -287,6 +296,7 @@ class MainViewModelTest : BaseViewModelTest() { val autofillSelectionData = mockk() every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false @@ -306,6 +316,36 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration`() { + val viewModel = createViewModel() + val completeRegistrationData = mockk() + val mockIntent = mockk { + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData + every { getAutofillSelectionDataOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + every { authRepository.activeUserId } returns null + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.CompleteRegistration( + completeRegistrationData = completeRegistrationData, + timestamp = FIXED_CLOCK.millis(), + ), + specialCircumstanceManager.specialCircumstance, + ) + } + @Suppress("MaxLineLength") @Test fun `on ReceiveFirstIntent with an autofill save item should set the special circumstance to AutofillSave`() { @@ -315,6 +355,7 @@ class MainViewModelTest : BaseViewModelTest() { every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -343,6 +384,7 @@ class MainViewModelTest : BaseViewModelTest() { } returns passwordlessRequestData every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -426,6 +468,7 @@ class MainViewModelTest : BaseViewModelTest() { every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns false } @@ -459,6 +502,7 @@ class MainViewModelTest : BaseViewModelTest() { every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns false } @@ -527,6 +571,7 @@ class MainViewModelTest : BaseViewModelTest() { every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -553,6 +598,7 @@ class MainViewModelTest : BaseViewModelTest() { val autofillSelectionData = mockk() every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns autofillSelectionData every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false @@ -581,6 +627,7 @@ class MainViewModelTest : BaseViewModelTest() { every { mockIntent.getPasswordlessRequestDataIntentOrNull() } returns null every { mockIntent.getAutofillSaveItemOrNull() } returns autofillSaveItem every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -609,6 +656,7 @@ class MainViewModelTest : BaseViewModelTest() { } returns passwordlessRequestData every { mockIntent.getAutofillSaveItemOrNull() } returns null every { mockIntent.getAutofillSelectionDataOrNull() } returns null + every { mockIntent.getCompleteRegistrationDataIntentOrNull() } returns null every { intentManager.getShareDataFromIntent(mockIntent) } returns null every { mockIntent.isMyVaultShortcut } returns false every { mockIntent.isPasswordGeneratorShortcut } returns false @@ -635,6 +683,7 @@ class MainViewModelTest : BaseViewModelTest() { every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns true every { isPasswordGeneratorShortcut } returns false } @@ -659,6 +708,7 @@ class MainViewModelTest : BaseViewModelTest() { every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns true } @@ -700,6 +750,7 @@ class MainViewModelTest : BaseViewModelTest() { settingsRepository = settingsRepository, vaultRepository = vaultRepository, authRepository = authRepository, + clock = FIXED_CLOCK, savedStateHandle = savedStateHandle.apply { set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance) }, @@ -740,6 +791,7 @@ private fun createMockFido2RegistrationIntent( every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns false } @@ -752,6 +804,7 @@ private fun createMockFido2AssertionIntent( every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSelectionDataOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns false } @@ -767,6 +820,12 @@ private fun createMockFido2GetCredentialsIntent( every { getAutofillSaveItemOrNull() } returns null every { getFido2CredentialRequestOrNull() } returns null every { getFido2AssertionRequestOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns null every { isMyVaultShortcut } returns false every { isPasswordGeneratorShortcut } returns false } + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt new file mode 100644 index 0000000000..068f17e7b3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/util/CompleteRegistrationDataUtilsTest.kt @@ -0,0 +1,121 @@ +package com.x8bit.bitwarden.data.auth.util + +import android.content.Intent +import android.net.Uri +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CompleteRegistrationDataUtilsTest { + + @BeforeEach + fun setup() { + mockkStatic(Uri::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull valid URI returns CompleteRegistrationData`() { + val mockIntent = mockk { + every { data } returns mockk() + } + val uriMock = mockk() + every { Uri.parse(any()) } returns uriMock + every { uriMock.host } returns "www.bitwarden.com" + every { uriMock.path } returns "/finish-signup" + every { uriMock.getQueryParameter("email") } returns "example@email.com" + every { uriMock.getQueryParameter("token") } returns "verificationtoken" + every { uriMock.getBooleanQueryParameter("fromEmail", true) } returns true + + assertEquals( + CompleteRegistrationData( + email = "example@email.com", + verificationToken = "verificationtoken", + fromEmail = true, + ), + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull null data returns null`() { + val mockIntent = mockk { + every { data } returns mockk() + } + every { Uri.parse(any()) } returns null + + assertNull( + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull Uri with no host`() { + val mockIntent = mockk { + every { data } returns mockk() + } + val uriMock = mockk() + every { Uri.parse(any()) } returns null + every { uriMock.host } returns null + + assertNull( + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull URI does not contain finish-signup path`() { + val mockIntent = mockk { + every { data } returns mockk() + } + val uriMock = mockk() + every { Uri.parse(any()) } returns null + every { uriMock.host } returns null + every { uriMock.path } returns "/finish" + assertNull( + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull URI does not contain parameter email`() { + val mockIntent = mockk { + every { data } returns mockk() + } + val uriMock = mockk() + every { Uri.parse(any()) } returns uriMock + every { uriMock.host } returns "www.bitwarden.com" + every { uriMock.path } returns "/finish-signup" + every { uriMock.getQueryParameter("email") } returns null + assertNull( + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } + + @Test + fun `getCompleteRegistrationDataIntentOrNull URI does not contain parameter token`() { + val mockIntent = mockk { + every { data } returns mockk() + } + val uriMock = mockk() + every { Uri.parse(any()) } returns uriMock + every { uriMock.host } returns "www.bitwarden.com" + every { uriMock.path } returns "/finish-signup" + every { uriMock.getQueryParameter("email") } returns "example@email.com" + every { uriMock.getQueryParameter("token") } returns null + assertNull( + mockIntent.getCompleteRegistrationDataIntentOrNull(), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index b9fac81fa9..a99a48f114 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentia import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -17,7 +18,11 @@ import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +@Suppress("LargeClass") class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { @@ -550,6 +555,129 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when there are no accounts but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() { + every { authRepository.hasPendingAccountAddition } returns false + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CompleteRegistration( + CompleteRegistrationData( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + ), + FIXED_CLOCK.instant().toEpochMilli(), + ) + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() + assertEquals( + RootNavState.CompleteOngoingRegistration( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + timestamp = FIXED_CLOCK.instant().toEpochMilli(), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when the active user has an unlocked vault but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() { + every { authRepository.hasPendingAccountAddition } returns true + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CompleteRegistration( + CompleteRegistrationData( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + ), + FIXED_CLOCK.instant().toEpochMilli(), + ) + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarHexColor", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.CompleteOngoingRegistration( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + timestamp = FIXED_CLOCK.instant().toEpochMilli(), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when the active user has a locked vault but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() { + every { authRepository.hasPendingAccountAddition } returns true + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CompleteRegistration( + CompleteRegistrationData( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + ), + FIXED_CLOCK.instant().toEpochMilli(), + ) + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = false, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.CompleteOngoingRegistration( + email = "example@email.com", + verificationToken = "token", + fromEmail = true, + timestamp = FIXED_CLOCK.instant().toEpochMilli(), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `when the active user has a locked vault the nav state should be VaultLocked`() { mutableUserStateFlow.tryEmit( @@ -583,4 +711,9 @@ class RootNavViewModelTest : BaseViewModelTest() { authRepository = authRepository, specialCircumstanceManager = specialCircumstanceManager, ) + + private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) }