mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-817, BIT-991: Add self-hosted/custom environment functionality (#209)
This commit is contained in:
parent
c67fa04d1c
commit
3755b0ed07
9 changed files with 458 additions and 41 deletions
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue