BIT-817, BIT-991: Add self-hosted/custom environment functionality (#209)

This commit is contained in:
Brian Yencho 2023-11-06 15:46:23 -06:00 committed by Álison Fernandes
parent c67fa04d1c
commit 3755b0ed07
9 changed files with 458 additions and 41 deletions

View file

@ -35,6 +35,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField 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()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier

View file

@ -3,9 +3,15 @@ package com.x8bit.bitwarden.ui.auth.feature.environment
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope 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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text 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.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidUri
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -20,17 +26,26 @@ private const val KEY_STATE = "state"
*/ */
@HiltViewModel @HiltViewModel
class EnvironmentViewModel @Inject constructor( class EnvironmentViewModel @Inject constructor(
private val environmentRepository: EnvironmentRepository,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<EnvironmentState, EnvironmentEvent, EnvironmentAction>( ) : BaseViewModel<EnvironmentState, EnvironmentEvent, EnvironmentAction>(
// TODO: Pull non-saved state from EnvironmentRepository (BIT-817) initialState = savedStateHandle[KEY_STATE] ?: run {
initialState = savedStateHandle[KEY_STATE] val environmentUrlData = when (val environment = environmentRepository.environment) {
?: EnvironmentState( Environment.Us,
serverUrl = "", Environment.Eu,
webVaultServerUrl = "", -> EnvironmentUrlDataJson(base = "")
apiServerUrl = "",
identityServerUrl = "", is Environment.SelfHosted -> environment.environmentUrlData
iconsServerUrl = "", }
), EnvironmentState(
serverUrl = environmentUrlData.base,
webVaultServerUrl = environmentUrlData.webVault.orEmpty(),
apiServerUrl = environmentUrlData.api.orEmpty(),
identityServerUrl = environmentUrlData.identity.orEmpty(),
iconsServerUrl = environmentUrlData.icon.orEmpty(),
shouldShowErrorDialog = false,
)
},
) { ) {
init { init {
@ -44,6 +59,7 @@ class EnvironmentViewModel @Inject constructor(
override fun handleAction(action: EnvironmentAction): Unit = when (action) { override fun handleAction(action: EnvironmentAction): Unit = when (action) {
is EnvironmentAction.CloseClick -> handleCloseClickAction() is EnvironmentAction.CloseClick -> handleCloseClickAction()
is EnvironmentAction.SaveClick -> handleSaveClickAction() is EnvironmentAction.SaveClick -> handleSaveClickAction()
is EnvironmentAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is EnvironmentAction.ServerUrlChange -> handleServerUrlChangeAction(action) is EnvironmentAction.ServerUrlChange -> handleServerUrlChangeAction(action)
is EnvironmentAction.WebVaultServerUrlChange -> handleWebVaultServerUrlChangeAction(action) is EnvironmentAction.WebVaultServerUrlChange -> handleWebVaultServerUrlChangeAction(action)
is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action) is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action)
@ -56,8 +72,48 @@ class EnvironmentViewModel @Inject constructor(
} }
private fun handleSaveClickAction() { private fun handleSaveClickAction() {
// TODO: Save custom value (BIT-817) val state = mutableStateFlow.value
sendEvent(EnvironmentEvent.ShowToast("Not yet implemented.".asText()))
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( private fun handleServerUrlChangeAction(
@ -111,6 +167,7 @@ data class EnvironmentState(
val apiServerUrl: String, val apiServerUrl: String,
val identityServerUrl: String, val identityServerUrl: String,
val iconsServerUrl: String, val iconsServerUrl: String,
val shouldShowErrorDialog: Boolean,
) : Parcelable ) : Parcelable
/** /**
@ -144,6 +201,11 @@ sealed class EnvironmentAction {
*/ */
data object SaveClick : EnvironmentAction() data object SaveClick : EnvironmentAction()
/**
* User dismissed an error dialog.
*/
data object ErrorDialogDismiss : EnvironmentAction()
/** /**
* Indicates that the overall server URL has changed. * Indicates that the overall server URL has changed.
*/ */
@ -179,3 +241,14 @@ sealed class EnvironmentAction {
val iconsServerUrl: String, val iconsServerUrl: String,
) : EnvironmentAction() ) : 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"
}

View file

@ -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.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository 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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text 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.asText
@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.net.URI
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -41,7 +43,7 @@ class LoginViewModel @Inject constructor(
emailAddress = LoginArgs(savedStateHandle).emailAddress, emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = true, isLoginButtonEnabled = true,
passwordInput = "", passwordInput = "",
environmentLabel = environmentRepository.environment.label, environmentLabel = environmentRepository.environment.labelOrBaseUrlHost,
loadingDialogState = LoadingDialogState.Hidden, loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden, errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken, captchaToken = LoginArgs(savedStateHandle).captchaToken,
@ -280,3 +282,23 @@ sealed class LoginAction {
) : Internal() ) : 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()
}
}

View file

@ -1,8 +1,23 @@
package com.x8bit.bitwarden.ui.platform.base.util package com.x8bit.bitwarden.ui.platform.base.util
import java.net.URI
/** /**
* Whether or not string is a valid email address. * Whether or not string is a valid email address.
* *
* This just checks if the string contains the "@" symbol. * This just checks if the string contains the "@" symbol.
*/ */
fun String.isValidEmail(): Boolean = contains("@") 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
}

View file

@ -1,6 +1,12 @@
package com.x8bit.bitwarden.ui.auth.feature.environment 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.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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick 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 @Test
fun `server URL should change according to the state`() { fun `server URL should change according to the state`() {
composeTestRule composeTestRule
@ -195,6 +244,7 @@ class EnvironmentScreenTest : BaseComposeTest() {
apiServerUrl = "", apiServerUrl = "",
identityServerUrl = "", identityServerUrl = "",
iconsServerUrl = "", iconsServerUrl = "",
shouldShowErrorDialog = false,
) )
} }
} }

View file

@ -2,6 +2,10 @@ package com.x8bit.bitwarden.ui.auth.feature.environment
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test 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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -10,8 +14,11 @@ import org.junit.jupiter.api.Test
class EnvironmentViewModelTest : BaseViewModelTest() { class EnvironmentViewModelTest : BaseViewModelTest() {
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@Suppress("MaxLineLength")
@Test @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() val viewModel = createViewModel()
assertEquals( assertEquals(
DEFAULT_STATE, 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 @Test
fun `initial state should be correct when restoring from the save state handle`() { fun `initial state should be correct when restoring from the save state handle`() {
val savedState = EnvironmentState( val savedState = DEFAULT_STATE.copy(
serverUrl = "saved-server", serverUrl = "saved-server",
webVaultServerUrl = "saved-web-vault", webVaultServerUrl = "saved-web-vault",
apiServerUrl = "saved-api", apiServerUrl = "saved-api",
@ -36,7 +69,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() {
), ),
) )
assertEquals( assertEquals(
EnvironmentState( DEFAULT_STATE.copy(
serverUrl = "saved-server", serverUrl = "saved-server",
webVaultServerUrl = "saved-web-vault", webVaultServerUrl = "saved-web-vault",
apiServerUrl = "saved-api", apiServerUrl = "saved-api",
@ -57,17 +90,149 @@ class EnvironmentViewModelTest : BaseViewModelTest() {
} }
@Test @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() val viewModel = createViewModel()
viewModel.eventFlow.test { // Update to valid absolute URL
viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) listOf(
assertEquals( EnvironmentAction.WebVaultServerUrlChange(
EnvironmentEvent.ShowToast("Not yet implemented.".asText()), webVaultServerUrl = "web vault",
awaitItem(), ),
) )
} .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 @Test
fun `ServerUrlChange should update the server URL`() { fun `ServerUrlChange should update the server URL`() {
val viewModel = createViewModel() val viewModel = createViewModel()
@ -134,6 +299,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() {
savedStateHandle: SavedStateHandle = SavedStateHandle(), savedStateHandle: SavedStateHandle = SavedStateHandle(),
): EnvironmentViewModel = ): EnvironmentViewModel =
EnvironmentViewModel( EnvironmentViewModel(
environmentRepository = fakeEnvironmentRepository,
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
) )
@ -146,6 +312,7 @@ class EnvironmentViewModelTest : BaseViewModelTest() {
apiServerUrl = "", apiServerUrl = "",
identityServerUrl = "", identityServerUrl = "",
iconsServerUrl = "", iconsServerUrl = "",
shouldShowErrorDialog = false,
) )
} }
} }

View file

@ -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 @Test
fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest { fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest {
val inputEnvironmentType = Environment.Type.EU val inputEnvironmentType = Environment.Type.EU

View file

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R 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.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult 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.CaptchaCallbackTokenResult
@ -44,7 +45,7 @@ class LoginViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `initial state should be correct`() = runTest { fun `initial state should be correct for non-custom Environments`() = runTest {
val viewModel = LoginViewModel( val viewModel = LoginViewModel(
authRepository = mockk { authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf() 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 @Test
fun `initial state should pull from handle when present`() = runTest { fun `initial state should pull from handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(

View file

@ -29,4 +29,42 @@ class StringExtensionTest {
assertTrue(it.isValidEmail()) 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())
}
}
} }