From 3755b0ed07f6faf0c636e11effaef435dc56352e Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Mon, 6 Nov 2023 15:46:23 -0600 Subject: [PATCH] BIT-817, BIT-991: Add self-hosted/custom environment functionality (#209) --- .../feature/environment/EnvironmentScreen.kt | 17 ++ .../environment/EnvironmentViewModel.kt | 95 ++++++++- .../ui/auth/feature/login/LoginViewModel.kt | 24 ++- .../ui/platform/base/util/StringExtensions.kt | 15 ++ .../environment/EnvironmentScreenTest.kt | 50 +++++ .../environment/EnvironmentViewModelTest.kt | 189 +++++++++++++++++- .../feature/landing/LandingViewModelTest.kt | 17 -- .../auth/feature/login/LoginViewModelTest.kt | 54 ++++- .../platform/base/util/StringExtensionTest.kt | 38 ++++ 9 files changed, 458 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt index 7212961f1..4b0865dd5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt @@ -35,6 +35,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField @@ -61,6 +64,20 @@ fun EnvironmentScreen( } } + BitwardenBasicDialog( + visibilityState = if (state.shouldShowErrorDialog) { + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.environment_page_urls_error.asText(), + ) + } else { + BasicDialogState.Hidden + }, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.ErrorDialogDismiss) } + }, + ) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt index c9e78f0d0..b1be7dfe4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt @@ -3,9 +3,15 @@ package com.x8bit.bitwarden.ui.auth.feature.environment import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.util.orNullIfBlank import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.isValidUri import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -20,17 +26,26 @@ private const val KEY_STATE = "state" */ @HiltViewModel class EnvironmentViewModel @Inject constructor( + private val environmentRepository: EnvironmentRepository, private val savedStateHandle: SavedStateHandle, ) : BaseViewModel( - // TODO: Pull non-saved state from EnvironmentRepository (BIT-817) - initialState = savedStateHandle[KEY_STATE] - ?: EnvironmentState( - serverUrl = "", - webVaultServerUrl = "", - apiServerUrl = "", - identityServerUrl = "", - iconsServerUrl = "", - ), + initialState = savedStateHandle[KEY_STATE] ?: run { + val environmentUrlData = when (val environment = environmentRepository.environment) { + Environment.Us, + Environment.Eu, + -> EnvironmentUrlDataJson(base = "") + + is Environment.SelfHosted -> environment.environmentUrlData + } + EnvironmentState( + serverUrl = environmentUrlData.base, + webVaultServerUrl = environmentUrlData.webVault.orEmpty(), + apiServerUrl = environmentUrlData.api.orEmpty(), + identityServerUrl = environmentUrlData.identity.orEmpty(), + iconsServerUrl = environmentUrlData.icon.orEmpty(), + shouldShowErrorDialog = false, + ) + }, ) { init { @@ -44,6 +59,7 @@ class EnvironmentViewModel @Inject constructor( override fun handleAction(action: EnvironmentAction): Unit = when (action) { is EnvironmentAction.CloseClick -> handleCloseClickAction() is EnvironmentAction.SaveClick -> handleSaveClickAction() + is EnvironmentAction.ErrorDialogDismiss -> handleErrorDialogDismiss() is EnvironmentAction.ServerUrlChange -> handleServerUrlChangeAction(action) is EnvironmentAction.WebVaultServerUrlChange -> handleWebVaultServerUrlChangeAction(action) is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action) @@ -56,8 +72,48 @@ class EnvironmentViewModel @Inject constructor( } private fun handleSaveClickAction() { - // TODO: Save custom value (BIT-817) - sendEvent(EnvironmentEvent.ShowToast("Not yet implemented.".asText())) + val state = mutableStateFlow.value + + val urlsAreAllNullOrValid = listOf( + state.serverUrl, + state.webVaultServerUrl, + state.apiServerUrl, + state.identityServerUrl, + state.iconsServerUrl, + ) + .map { it.orNullIfBlank() } + .all { url -> + url == null || url.isValidUri() + } + + if (!urlsAreAllNullOrValid) { + mutableStateFlow.update { it.copy(shouldShowErrorDialog = true) } + return + } + + // Ensure all non-null/non-empty values have "http(s)://" prefixed. + val updatedServerUrl = state.serverUrl.prefixHttpsIfNecessaryOrNull() ?: "" + val updatedWebVaultServerUrl = state.webVaultServerUrl.prefixHttpsIfNecessaryOrNull() + val updatedApiServerUrl = state.apiServerUrl.prefixHttpsIfNecessaryOrNull() + val updatedIdentityServerUrl = state.identityServerUrl.prefixHttpsIfNecessaryOrNull() + val updatedIconsServerUrl = state.iconsServerUrl.prefixHttpsIfNecessaryOrNull() + + environmentRepository.environment = Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = updatedServerUrl, + api = updatedApiServerUrl, + identity = updatedIdentityServerUrl, + icon = updatedIconsServerUrl, + webVault = updatedWebVaultServerUrl, + ), + ) + + sendEvent(EnvironmentEvent.ShowToast(message = R.string.environment_saved.asText())) + sendEvent(EnvironmentEvent.NavigateBack) + } + + private fun handleErrorDialogDismiss() { + mutableStateFlow.update { it.copy(shouldShowErrorDialog = false) } } private fun handleServerUrlChangeAction( @@ -111,6 +167,7 @@ data class EnvironmentState( val apiServerUrl: String, val identityServerUrl: String, val iconsServerUrl: String, + val shouldShowErrorDialog: Boolean, ) : Parcelable /** @@ -144,6 +201,11 @@ sealed class EnvironmentAction { */ data object SaveClick : EnvironmentAction() + /** + * User dismissed an error dialog. + */ + data object ErrorDialogDismiss : EnvironmentAction() + /** * Indicates that the overall server URL has changed. */ @@ -179,3 +241,14 @@ sealed class EnvironmentAction { val iconsServerUrl: String, ) : EnvironmentAction() } + +/** + * If the given [String] is a valid URI, "https://" will be appended if it is not already present. + * Otherwise `null` will be returned. + */ +private fun String.prefixHttpsIfNecessaryOrNull(): String? = + when { + this.isBlank() || !this.isValidUri() -> null + "http://" in this || "https://" in this -> this + else -> "https://$this" + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 11941ab65..cb4f34d18 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import java.net.URI import javax.inject.Inject private const val KEY_STATE = "state" @@ -41,7 +43,7 @@ class LoginViewModel @Inject constructor( emailAddress = LoginArgs(savedStateHandle).emailAddress, isLoginButtonEnabled = true, passwordInput = "", - environmentLabel = environmentRepository.environment.label, + environmentLabel = environmentRepository.environment.labelOrBaseUrlHost, loadingDialogState = LoadingDialogState.Hidden, errorDialogState = BasicDialogState.Hidden, captchaToken = LoginArgs(savedStateHandle).captchaToken, @@ -280,3 +282,23 @@ sealed class LoginAction { ) : Internal() } } + +/** + * Returns the [Environment.label] for non-custom values. Otherwise returns the host of the + * custom base URL. + */ +private val Environment.labelOrBaseUrlHost: Text + get() = when (this) { + is Environment.Us -> this.label + is Environment.Eu -> this.label + is Environment.SelfHosted -> { + // Grab the domain + // Ex: + // - "https://www.abc.com/path-1/path-1" -> "www.abc.com" + URI + .create(this.environmentUrlData.base) + .host + .orEmpty() + .asText() + } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt index 432b9fa9c..0fe5c93f5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -1,8 +1,23 @@ package com.x8bit.bitwarden.ui.platform.base.util +import java.net.URI + /** * Whether or not string is a valid email address. * * This just checks if the string contains the "@" symbol. */ fun String.isValidEmail(): Boolean = contains("@") + +/** + * Returns `true` if the given [String] is a non-blank, valid URI and `false` otherwise. + * + * Note that this does not require the URI to contain a URL scheme like `https://`. + */ +fun String.isValidUri(): Boolean = + try { + URI.create(this) + this.isNotBlank() + } catch (_: IllegalArgumentException) { + false + } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt index 032e180f5..46f443993 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt @@ -1,6 +1,12 @@ package com.x8bit.bitwarden.ui.auth.feature.environment +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -59,6 +65,49 @@ class EnvironmentScreenTest : BaseComposeTest() { } } + @Test + fun `error dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + shouldShowErrorDialog = true, + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("An error has occurred.") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + "One or more of the URLs entered are invalid. " + + "Please revise it and try to save again.", + ) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Ok") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `error dialog OK click should send ErrorDialogDismiss action`() { + mutableStateFlow.update { + it.copy( + shouldShowErrorDialog = true, + ) + } + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(EnvironmentAction.ErrorDialogDismiss) } + } + @Test fun `server URL should change according to the state`() { composeTestRule @@ -195,6 +244,7 @@ class EnvironmentScreenTest : BaseComposeTest() { apiServerUrl = "", identityServerUrl = "", iconsServerUrl = "", + shouldShowErrorDialog = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt index b748921ba..21138bb72 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt @@ -2,6 +2,10 @@ package com.x8bit.bitwarden.ui.auth.feature.environment import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import kotlinx.coroutines.test.runTest @@ -10,8 +14,11 @@ import org.junit.jupiter.api.Test class EnvironmentViewModelTest : BaseViewModelTest() { + private val fakeEnvironmentRepository = FakeEnvironmentRepository() + + @Suppress("MaxLineLength") @Test - fun `initial state should be correct when there is no saved state`() { + fun `initial state should be correct when there is no saved state and the current environment is not self-hosted`() { val viewModel = createViewModel() assertEquals( DEFAULT_STATE, @@ -19,9 +26,35 @@ class EnvironmentViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `initial state should be correct when there is no saved state and the current environment is self-hosted`() { + val selfHostedEnvironmentUrlData = EnvironmentUrlDataJson( + base = "self-hosted-base", + api = "self-hosted-api", + identity = "self-hosted-identity", + icon = "self-hosted-icons", + webVault = "self-hosted-web-vault", + ) + fakeEnvironmentRepository.environment = Environment.SelfHosted( + environmentUrlData = selfHostedEnvironmentUrlData, + ) + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy( + serverUrl = "self-hosted-base", + webVaultServerUrl = "self-hosted-web-vault", + apiServerUrl = "self-hosted-api", + identityServerUrl = "self-hosted-identity", + iconsServerUrl = "self-hosted-icons", + ), + viewModel.stateFlow.value, + ) + } + @Test fun `initial state should be correct when restoring from the save state handle`() { - val savedState = EnvironmentState( + val savedState = DEFAULT_STATE.copy( serverUrl = "saved-server", webVaultServerUrl = "saved-web-vault", apiServerUrl = "saved-api", @@ -36,7 +69,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { ), ) assertEquals( - EnvironmentState( + DEFAULT_STATE.copy( serverUrl = "saved-server", webVaultServerUrl = "saved-web-vault", apiServerUrl = "saved-api", @@ -57,17 +90,149 @@ class EnvironmentViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick should emit ShowTest`() = runTest { + fun `SaveClick should show the error dialog when any URLs are invalid`() = runTest { + assertEquals( + Environment.Us, + fakeEnvironmentRepository.environment, + ) + val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) - assertEquals( - EnvironmentEvent.ShowToast("Not yet implemented.".asText()), - awaitItem(), - ) - } + // Update to valid absolute URL + listOf( + EnvironmentAction.WebVaultServerUrlChange( + webVaultServerUrl = "web vault", + ), + ) + .forEach { viewModel.trySendAction(it) } + + val initialState = DEFAULT_STATE.copy(webVaultServerUrl = "web vault") + assertEquals( + initialState, + viewModel.stateFlow.value, + ) + + viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) + + assertEquals( + initialState.copy( + shouldShowErrorDialog = true, + ), + viewModel.stateFlow.value, + ) + + // The Environment has not been updated + assertEquals( + Environment.Us, + fakeEnvironmentRepository.environment, + ) } + @Suppress("MaxLineLength") + @Test + fun `SaveClick should emit NavigateBack and ShowToast and update the environment when all URLs are valid`() = + runTest { + assertEquals( + Environment.Us, + fakeEnvironmentRepository.environment, + ) + + val viewModel = createViewModel() + // Update to valid absolute or relative URLs + listOf( + EnvironmentAction.ServerUrlChange( + serverUrl = "https://server-url", + ), + EnvironmentAction.WebVaultServerUrlChange( + webVaultServerUrl = "http://web-vault-url", + ), + EnvironmentAction.ApiServerUrlChange( + apiServerUrl = "api-url", + ), + EnvironmentAction.IdentityServerUrlChange( + identityServerUrl = "identity-url", + ), + EnvironmentAction.IconsServerUrlChange( + iconsServerUrl = "icons-url", + ), + ) + .forEach { viewModel.trySendAction(it) } + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) + + assertEquals( + EnvironmentEvent.ShowToast(R.string.environment_saved.asText()), + awaitItem(), + ) + assertEquals( + EnvironmentEvent.NavigateBack, + awaitItem(), + ) + // All the updated URLs should be prefixed with "https://" or "http://" + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "https://server-url", + api = "https://api-url", + identity = "https://identity-url", + icon = "https://icons-url", + notifications = null, + webVault = "http://web-vault-url", + events = null, + ), + ), + fakeEnvironmentRepository.environment, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SaveClick should emit NavigateBack and ShowToast and update the environment when some URLs are valid and others are null`() = + runTest { + assertEquals( + Environment.Us, + fakeEnvironmentRepository.environment, + ) + + val viewModel = createViewModel() + // Update to valid absolute URL + listOf( + EnvironmentAction.WebVaultServerUrlChange( + webVaultServerUrl = "http://web-vault-url", + ), + ) + .forEach { viewModel.trySendAction(it) } + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) + + assertEquals( + EnvironmentEvent.ShowToast(R.string.environment_saved.asText()), + awaitItem(), + ) + assertEquals( + EnvironmentEvent.NavigateBack, + awaitItem(), + ) + // All the updated URLs should be prefixed with "https://" or "http://" + assertEquals( + Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + api = null, + identity = null, + icon = null, + notifications = null, + webVault = "http://web-vault-url", + events = null, + ), + ), + fakeEnvironmentRepository.environment, + ) + } + } + @Test fun `ServerUrlChange should update the server URL`() { val viewModel = createViewModel() @@ -134,6 +299,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { savedStateHandle: SavedStateHandle = SavedStateHandle(), ): EnvironmentViewModel = EnvironmentViewModel( + environmentRepository = fakeEnvironmentRepository, savedStateHandle = savedStateHandle, ) @@ -146,6 +312,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() { apiServerUrl = "", identityServerUrl = "", iconsServerUrl = "", + shouldShowErrorDialog = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 555b541d8..ac344c776 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -149,23 +149,6 @@ class LandingViewModelTest : BaseViewModelTest() { } } - @Test - fun `external environment updates should update the selected environment type`() = runTest { - val viewModel = createViewModel() - viewModel.stateFlow.test { - assertEquals(DEFAULT_STATE, awaitItem()) - - fakeEnvironmentRepository.environment = Environment.Eu - - assertEquals( - DEFAULT_STATE.copy( - selectedEnvironmentType = Environment.Type.EU, - ), - awaitItem(), - ) - } - } - @Test fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest { val inputEnvironmentType = Environment.Type.EU diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 00bdabeb3..f73b4ff35 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -44,7 +45,7 @@ class LoginViewModelTest : BaseViewModelTest() { } @Test - fun `initial state should be correct`() = runTest { + fun `initial state should be correct for non-custom Environments`() = runTest { val viewModel = LoginViewModel( authRepository = mockk { every { captchaTokenResultFlow } returns flowOf() @@ -59,6 +60,57 @@ class LoginViewModelTest : BaseViewModelTest() { } } + @Test + fun `initial state should be correct for custom Environments with empty base URLs`() = runTest { + val viewModel = LoginViewModel( + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, + environmentRepository = mockk { + every { environment } returns Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "", + ), + ) + }, + savedStateHandle = savedStateHandle, + ) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + environmentLabel = "".asText(), + ), + awaitItem(), + ) + } + } + + @Test + fun `initial state should be correct for custom Environments with non-empty base URLs`() = + runTest { + val viewModel = LoginViewModel( + authRepository = mockk { + every { captchaTokenResultFlow } returns flowOf() + }, + environmentRepository = mockk { + every { environment } returns Environment.SelfHosted( + environmentUrlData = EnvironmentUrlDataJson( + base = "https://abc.com/path-1/path-2", + ), + ) + }, + savedStateHandle = savedStateHandle, + ) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + environmentLabel = "abc.com".asText(), + ), + awaitItem(), + ) + } + } + @Test fun `initial state should pull from handle when present`() = runTest { val expectedState = DEFAULT_STATE.copy( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt index afe34ae47..2ccea4292 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionTest.kt @@ -29,4 +29,42 @@ class StringExtensionTest { assertTrue(it.isValidEmail()) } } + + @Test + fun `isValidUri should return true for an absolute URL`() { + assertTrue("https://abc.com".isValidUri()) + } + + @Test + fun `isValidUri should return true for an absolute non-URL path`() { + assertTrue("file:///abc/com".isValidUri()) + } + + @Test + fun `isValidUri should return true for a relative URI`() { + assertTrue("abc.com".isValidUri()) + } + + @Test + fun `isValidUri should return false for a blank or empty String`() { + listOf( + "", + " ", + ) + .forEach { badUri -> + assertFalse(badUri.isValidUri()) + } + } + + @Test + fun `isValidUri should return false when there are invalid characters present`() { + listOf( + "abc com", + "abc<>com", + "abc[]com", + ) + .forEach { badUri -> + assertFalse(badUri.isValidUri()) + } + } }