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

View file

@ -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<EnvironmentState, EnvironmentEvent, EnvironmentAction>(
// 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"
}

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.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()
}
}

View file

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

View file

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

View file

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

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

View file

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

View file

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