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 GitHub
parent 236c62cdd3
commit d5879f1cd4
9 changed files with 458 additions and 41 deletions

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