[PM-6702] 3# Open app from App Link to CompleteRegistration (#3619)

This commit is contained in:
André Bispo 2024-08-15 14:28:35 +01:00 committed by GitHub
parent 524b9e9a08
commit e2cd3867dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 429 additions and 3 deletions

View file

@ -55,6 +55,17 @@
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.pw" />
<data android:host="bitwarden.eu" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />

View file

@ -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<MainState, MainEvent, MainAction>(
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(

View file

@ -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,
)
}

View file

@ -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

View file

@ -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.

View file

@ -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
}

View file

@ -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
}
}
}
}

View file

@ -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.
*/

View file

@ -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<AutofillSelectionData>()
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<CompleteRegistrationData>()
val mockIntent = mockk<Intent> {
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<AutofillSelectionData>()
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,
)

View file

@ -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<Intent> {
every { data } returns mockk()
}
val uriMock = mockk<Uri>()
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<Intent> {
every { data } returns mockk()
}
every { Uri.parse(any()) } returns null
assertNull(
mockIntent.getCompleteRegistrationDataIntentOrNull(),
)
}
@Test
fun `getCompleteRegistrationDataIntentOrNull Uri with no host`() {
val mockIntent = mockk<Intent> {
every { data } returns mockk()
}
val uriMock = mockk<Uri>()
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<Intent> {
every { data } returns mockk()
}
val uriMock = mockk<Uri>()
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<Intent> {
every { data } returns mockk()
}
val uriMock = mockk<Uri>()
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<Intent> {
every { data } returns mockk()
}
val uriMock = mockk<Uri>()
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(),
)
}
}

View file

@ -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<UserState?>(null)
private val authRepository = mockk<AuthRepository> {
@ -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,
)
}