From bb2e98eac6d056071bc05407bd7dee578937f4c3 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 18 Feb 2025 08:57:47 -0500 Subject: [PATCH] [PM-14936] Add AnonAddy self-hosted server URL support (#4708) --- .../model/UsernameGenerationOptions.kt | 4 + .../ui/platform/base/util/StringExtensions.kt | 7 + .../feature/generator/GeneratorScreen.kt | 20 +++ .../feature/generator/GeneratorViewModel.kt | 97 +++++++++++++- .../handlers/ForwardedEmailAliasHandlers.kt | 7 + .../ForwardedEmailServiceTypeExtensions.kt | 4 + .../generator/util/ServiceTypeExtensions.kt | 13 +- app/src/main/res/values/strings.xml | 1 + .../feature/generator/GeneratorScreenTest.kt | 80 ++++++++++++ .../generator/GeneratorViewModelTest.kt | 70 ++++++++-- ...ForwardedEmailServiceTypeExtensionsTest.kt | 75 +++++++++-- .../util/ServiceTypeExtensionsTest.kt | 121 +++++++++++++++--- 12 files changed, 454 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt index c3ac9667c6..0ae4409500 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/model/UsernameGenerationOptions.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.Serializable * @property fastMailApiKey The API key for FastMail. * @property anonAddyApiAccessToken The API access token for AnonAddy. * @property anonAddyDomainName The domain name associated with AnonAddy. + * @property anonAddySelfHostServerUrl The self-hosted server URL for AnonAddy. * @property forwardEmailApiAccessToken The API access token for Forward Email. * @property forwardEmailDomainName The domain name associated with Forward Email. * @property emailWebsite The website associated with the email service. @@ -63,6 +64,9 @@ data class UsernameGenerationOptions( @SerialName("anonAddyDomainName") val anonAddyDomainName: String? = null, + @SerialName("anonAddyBaseUrl") + val anonAddySelfHostServerUrl: String? = null, + @SerialName("forwardEmailApiAccessToken") val forwardEmailApiAccessToken: String? = null, 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 6cbb0c48ea..968c8e6691 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 @@ -215,3 +215,10 @@ fun String.prefixHttpsIfNecessaryOrNull(): String? = "http://" in this || "https://" in this -> this else -> "https://$this" } + +/** + * If the given [String] is a valid URI, "https://" will be appended if it is not already present. + * Otherwise the original [String] will be returned. + */ +fun String.prefixHttpsIfNecessary(): String = + prefixHttpsIfNecessaryOrNull() ?: this diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 44ee371011..9bcae82a59 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -528,6 +528,8 @@ private fun CoachMarkScope.ScrollContent( plusAddressedEmailHandlers = plusAddressedEmailHandlers, catchAllEmailHandlers = catchAllEmailHandlers, randomWordHandlers = randomWordHandlers, + shouldShowSelfHostServerUrlField = + state.shouldShowAnonAddySelfHostServerUrlField, ) } } @@ -1101,6 +1103,7 @@ private fun UsernameTypeItems( plusAddressedEmailHandlers: PlusAddressedEmailHandlers, catchAllEmailHandlers: CatchAllEmailHandlers, randomWordHandlers: RandomWordHandlers, + shouldShowSelfHostServerUrlField: Boolean, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -1125,6 +1128,7 @@ private fun UsernameTypeItems( ForwardedEmailAliasTypeContent( usernameTypeState = selectedType, forwardedEmailAliasHandlers = forwardedEmailAliasHandlers, + shouldShowSelfHostServerUrlField = shouldShowSelfHostServerUrlField, ) } @@ -1185,6 +1189,7 @@ private fun UsernameOptionsItem( private fun ForwardedEmailAliasTypeContent( usernameTypeState: GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias, forwardedEmailAliasHandlers: ForwardedEmailAliasHandlers, + shouldShowSelfHostServerUrlField: Boolean, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -1226,6 +1231,21 @@ private fun ForwardedEmailAliasTypeContent( .standardHorizontalMargin() .fillMaxWidth(), ) + + if (shouldShowSelfHostServerUrlField) { + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.self_host_server_url), + value = usernameTypeState.selectedServiceType.selfHostServerUrl, + onValueChange = forwardedEmailAliasHandlers.onAddyIoSelfHostServerUrlChange, + textFieldTestTag = "AnonAddySelfHostUrlEntry", + cardStyle = CardStyle.Full, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + } } is ServiceType.DuckDuckGo -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index 6d7f6af2b1..c6d7594ef0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -12,11 +12,13 @@ import com.bitwarden.generators.UsernameGeneratorRequest import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies import com.x8bit.bitwarden.data.platform.manager.util.getActivePoliciesFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository @@ -80,6 +82,7 @@ class GeneratorViewModel @Inject constructor( private val policyManager: PolicyManager, private val reviewPromptManager: ReviewPromptManager, private val firstTimeActionManager: FirstTimeActionManager, + private val featureFlagManager: FeatureFlagManager, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { val generatorMode = GeneratorArgs(savedStateHandle).type @@ -109,6 +112,9 @@ class GeneratorViewModel @Inject constructor( .any(), website = (generatorMode as? GeneratorMode.Modal.Username)?.website, shouldShowCoachMarkTour = false, + shouldShowAnonAddySelfHostServerUrlField = featureFlagManager.getFeatureFlag( + FlagKey.AnonAddySelfHostAlias, + ), ) }, ) { @@ -135,6 +141,17 @@ class GeneratorViewModel @Inject constructor( } .onEach(::sendAction) .launchIn(viewModelScope) + + featureFlagManager + .getFeatureFlagFlow(FlagKey.AnonAddySelfHostAlias) + .map { shouldShowAnonAddySelfHostServerUrlField -> + GeneratorAction.Internal.ShouldShowAnonAddySelfHostValueChangeReceive( + shouldShowAnonAddySelfHostServerUrlField = + shouldShowAnonAddySelfHostServerUrlField, + ) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: GeneratorAction) { @@ -268,6 +285,10 @@ class GeneratorViewModel @Inject constructor( is GeneratorAction.Internal.ShouldShowGeneratorCoachMarkValueChangeReceive -> { handleShouldShowCoachMarkValueChange(action) } + + is GeneratorAction.Internal.ShouldShowAnonAddySelfHostValueChangeReceive -> { + handleShouldShowAnonAddySelfHostValueChange(action) + } } } @@ -547,6 +568,8 @@ class GeneratorViewModel @Inject constructor( serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.ANON_ADDY, anonAddyApiAccessToken = forwardedEmailAlias.selectedServiceType.apiAccessToken, anonAddyDomainName = forwardedEmailAlias.selectedServiceType.domainName, + anonAddySelfHostServerUrl = + forwardedEmailAlias.selectedServiceType.selfHostServerUrl, ) is DuckDuckGo -> options.copy( @@ -624,6 +647,7 @@ class GeneratorViewModel @Inject constructor( fastMailApiKey = "", anonAddyApiAccessToken = "", anonAddyDomainName = "", + anonAddySelfHostServerUrl = "", forwardEmailApiAccessToken = "", forwardEmailDomainName = "", emailWebsite = "", @@ -1079,6 +1103,7 @@ class GeneratorViewModel @Inject constructor( selectedServiceType = AddyIo( apiAccessToken = options.anonAddyApiAccessToken.orEmpty(), domainName = options.anonAddyDomainName.orEmpty(), + selfHostServerUrl = options.anonAddySelfHostServerUrl.orEmpty(), ), ) } @@ -1155,6 +1180,17 @@ class GeneratorViewModel @Inject constructor( -> { handleAddyIoDomainNameTextChange(action) } + + is GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .AddyIo + .SelfHostServerUrlChange, + -> { + handleAddyIoSelfHostServerUrlChange(action) + } } } @@ -1188,6 +1224,31 @@ class GeneratorViewModel @Inject constructor( } } + private fun handleShouldShowAnonAddySelfHostValueChange( + action: GeneratorAction.Internal.ShouldShowAnonAddySelfHostValueChangeReceive, + ) { + mutableStateFlow.update { + it.copy( + shouldShowAnonAddySelfHostServerUrlField = + action.shouldShowAnonAddySelfHostServerUrlField, + ) + } + } + + private fun handleAddyIoSelfHostServerUrlChange( + action: GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .AddyIo + .SelfHostServerUrlChange, + ) { + updateAddyIoServiceType { addyIoServiceType -> + addyIoServiceType.copy(selfHostServerUrl = action.url) + } + } + //endregion Addy.Io Service Specific Handlers //region DuckDuckGo Service Specific Handlers @@ -1472,10 +1533,16 @@ class GeneratorViewModel @Inject constructor( } private suspend fun generateForwardedEmailAlias(alias: ForwardedEmailAlias) { - val request = alias.selectedServiceType?.toUsernameGeneratorRequest(state.website) ?: run { - mutableStateFlow.update { it.copy(generatedText = NO_GENERATED_TEXT) } - return - } + val request = alias + .selectedServiceType + ?.toUsernameGeneratorRequest( + website = state.website, + allowAddyIoSelfHostUrl = state.shouldShowAnonAddySelfHostServerUrlField, + ) + ?: run { + mutableStateFlow.update { it.copy(generatedText = NO_GENERATED_TEXT) } + return + } val result = generatorRepository.generateForwardedServiceUsername(request) sendAction(GeneratorAction.Internal.UpdateGeneratedForwardedServiceUsernameResult(result)) } @@ -1772,6 +1839,7 @@ data class GeneratorState( val website: String? = null, var passcodePolicyOverride: PasscodePolicyOverride? = null, private val shouldShowCoachMarkTour: Boolean, + val shouldShowAnonAddySelfHostServerUrlField: Boolean, ) : Parcelable { /** @@ -2097,10 +2165,15 @@ data class GeneratorState( data class AddyIo( val apiAccessToken: String = "", val domainName: String = "", - val baseUrl: String = "https://app.addy.io", + val selfHostServerUrl: String = "", ) : ServiceType(), Parcelable { override val displayStringResId: Int get() = ServiceTypeOption.ADDY_IO.labelRes + + @Suppress("UndocumentedPublicClass") + companion object { + const val DEFAULT_ADDY_IO_URL = "https://app.addy.io" + } } /** @@ -2434,6 +2507,13 @@ sealed class GeneratorAction { * @property domain The new domain text. */ data class DomainTextChange(val domain: String) : AddyIo() + + /** + * Fired when the self host server url input text is changed. + * + * @property url The new self host server url text. + */ + data class SelfHostServerUrlChange(val url: String) : AddyIo() } /** @@ -2617,6 +2697,13 @@ sealed class GeneratorAction { data class ShouldShowGeneratorCoachMarkValueChangeReceive( val shouldShowCoachMarkTour: Boolean, ) : Internal() + + /** + * The value for the shouldShowAnonAddySelfHostServerUrlField feature flag has changed. + */ + data class ShouldShowAnonAddySelfHostValueChangeReceive( + val shouldShowAnonAddySelfHostServerUrlField: Boolean, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/handlers/ForwardedEmailAliasHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/handlers/ForwardedEmailAliasHandlers.kt index 916b467d57..e5abf4030f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/handlers/ForwardedEmailAliasHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/handlers/ForwardedEmailAliasHandlers.kt @@ -17,6 +17,7 @@ data class ForwardedEmailAliasHandlers( val onServiceChange: (ForwardedEmailAlias.ServiceTypeOption) -> Unit, val onAddyIoAccessTokenTextChange: (String) -> Unit, val onAddyIoDomainNameTextChange: (String) -> Unit, + val onAddyIoSelfHostServerUrlChange: (String) -> Unit, val onDuckDuckGoApiKeyTextChange: (String) -> Unit, val onFastMailApiKeyTextChange: (String) -> Unit, val onFirefoxRelayAccessTokenTextChange: (String) -> Unit, @@ -30,6 +31,7 @@ data class ForwardedEmailAliasHandlers( * Creates an instance of [ForwardedEmailAliasHandlers] by binding actions to the provided * [GeneratorViewModel]. */ + @Suppress("LongMethod") fun create( viewModel: GeneratorViewModel, ): ForwardedEmailAliasHandlers = ForwardedEmailAliasHandlers( @@ -52,6 +54,11 @@ data class ForwardedEmailAliasHandlers( ForwardedEmailAliasAction.AddyIo.DomainTextChange(domain = newDomainName), ) }, + onAddyIoSelfHostServerUrlChange = { newServerUrl -> + viewModel.trySendAction( + ForwardedEmailAliasAction.AddyIo.SelfHostServerUrlChange(url = newServerUrl), + ) + }, onDuckDuckGoApiKeyTextChange = { newApiKey -> viewModel.trySendAction( ForwardedEmailAliasAction.DuckDuckGo.ApiKeyTextChange(apiKey = newApiKey), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensions.kt index 739b60ed36..8ea2c1ce07 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.util import com.x8bit.bitwarden.data.tools.generator.repository.model.UsernameGenerationOptions +import com.x8bit.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.AddyIo import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType.DuckDuckGo @@ -38,6 +39,9 @@ fun UsernameGenerationOptions.ForwardedEmailServiceType?.toServiceType( AddyIo( apiAccessToken = options.anonAddyApiAccessToken.orEmpty(), domainName = options.anonAddyDomainName.orEmpty(), + selfHostServerUrl = options.anonAddySelfHostServerUrl + ?.prefixHttpsIfNecessary() + .orEmpty(), ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensions.kt index 5ba7031a30..1183609d2e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensions.kt @@ -3,22 +3,31 @@ package com.x8bit.bitwarden.ui.tools.feature.generator.util import com.bitwarden.generators.ForwarderServiceType import com.bitwarden.generators.UsernameGeneratorRequest import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank +import com.x8bit.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias.ServiceType /** * Converts a [ServiceType] to a [UsernameGeneratorRequest.Forwarded]. */ @Suppress("LongMethod") -fun ServiceType.toUsernameGeneratorRequest(website: String?): UsernameGeneratorRequest.Forwarded? { +fun ServiceType.toUsernameGeneratorRequest( + website: String?, + allowAddyIoSelfHostUrl: Boolean, +): UsernameGeneratorRequest.Forwarded? { return when (this) { is ServiceType.AddyIo -> { val accessToken = this.apiAccessToken.orNullIfBlank() ?: return null val domain = this.domainName.orNullIfBlank() ?: return null + val baseUrl = if (allowAddyIoSelfHostUrl && selfHostServerUrl.isNotBlank()) { + selfHostServerUrl.prefixHttpsIfNecessary() + } else { + ServiceType.AddyIo.DEFAULT_ADDY_IO_URL + } UsernameGeneratorRequest.Forwarded( service = ForwarderServiceType.AddyIo( apiToken = accessToken, domain = domain, - baseUrl = this.baseUrl, + baseUrl = baseUrl, ), website = website, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa80c8b7a4..b2815bf3bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1209,4 +1209,5 @@ Do you want to switch to this account? Passkey operation failed because the request is invalid. Passkey operation failed because user verification attempts exceeded. Passkey operation failed because no item was selected. + Self-host server URL diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 0ed929118b..c7208ddd21 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals @@ -1124,6 +1125,84 @@ class GeneratorScreenTest : BaseComposeTest() { } } + @Suppress("MaxLineLength") + @Test + fun `in Username_ForwardedEmailAlias_AddyIo state, updating self host server url text input should send SelfHostServerUrlChange action`() { + updateState( + DEFAULT_STATE.copy( + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( + selectedServiceType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceType + .AddyIo(), + ), + ), + ), + ) + + val newServerUrl = "https://addyio.local" + + composeTestRule + .onNodeWithText("Self-host server URL") + .performScrollTo() + .performTextInput(newServerUrl) + + verify { + viewModel.trySendAction( + GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .AddyIo + .SelfHostServerUrlChange( + url = newServerUrl, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in Username_ForwardedEmailAlias_AddyIo state, self host server url field should show based on state`() { + updateState( + DEFAULT_STATE.copy( + shouldShowAnonAddySelfHostServerUrlField = true, + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( + selectedServiceType = GeneratorState + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .ServiceType + .AddyIo(), + ), + ), + ), + ) + + composeTestRule + .onNodeWithText("Self-host server URL") + .performScrollTo() + .assertIsDisplayed() + + // Simulate Disabling the feature flag + updateState( + DEFAULT_STATE.copy( + shouldShowAnonAddySelfHostServerUrlField = false, + ), + ) + + composeTestRule + .onNodeWithText("Self-host server URL") + .assertIsNotDisplayed() + } + //endregion Addy.Io Service Type Tests //region DuckDuckGo Service Type Tests @@ -1773,4 +1852,5 @@ private val DEFAULT_STATE = GeneratorState( selectedType = GeneratorState.MainType.Password(), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = false, + shouldShowAnonAddySelfHostServerUrlField = true, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index 14a803eb05..999feb6294 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -8,12 +8,14 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedCatchAllUsernameResult @@ -119,6 +121,13 @@ class GeneratorViewModelTest : BaseViewModelTest() { every { markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR) } just runs every { shouldShowGeneratorCoachMarkFlow } returns mutableShouldShowGeneratorCoachMarkFlow } + private val mutableAnonAddySelfHostAliasFlow = MutableStateFlow(true) + private val featureFlagManager = mockk { + every { getFeatureFlag(FlagKey.AnonAddySelfHostAlias) } returns true + every { + getFeatureFlagFlow(FlagKey.AnonAddySelfHostAlias) + } returns mutableAnonAddySelfHostAliasFlow + } @Test fun `initial state should be correct when there is no saved state`() { @@ -148,9 +157,9 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), generatorMode = GeneratorMode.Modal.Username(website = ""), currentEmailAddress = "currentEmail", - isUnderPolicy = false, website = "", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) val viewModel = createViewModel( @@ -184,9 +193,8 @@ class GeneratorViewModelTest : BaseViewModelTest() { selectedType = GeneratorState.MainType.Passphrase(), generatorMode = GeneratorMode.Modal.Password, currentEmailAddress = "currentEmail", - isUnderPolicy = false, - website = null, shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) val viewModel = createViewModel( @@ -1696,9 +1704,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { selectedType = GeneratorState.MainType.Username( GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( selectedServiceType = ServiceType - .AddyIo( - apiAccessToken = newAccessToken, - ), + .AddyIo(apiAccessToken = newAccessToken), ), ), ) @@ -1726,13 +1732,47 @@ class GeneratorViewModelTest : BaseViewModelTest() { viewModel.trySendAction(action) + val expectedState = defaultAddyIoState.copy( + generatedText = "-", + selectedType = GeneratorState.MainType.Username( + GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( + selectedServiceType = ServiceType + .AddyIo(domainName = newDomainName), + ), + ), + ) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `ServerUrlTextChange should update the server text correctly`() = runTest { + val newSelfHostServerUrl = "https://selfhost.alias" + val action = GeneratorAction + .MainType + .Username + .UsernameType + .ForwardedEmailAlias + .AddyIo + .SelfHostServerUrlChange( + url = newSelfHostServerUrl, + ) + + fakeGeneratorRepository.setMockGenerateForwardedServiceResult( + GeneratedForwardedServiceUsernameResult.Success( + generatedEmailAddress = "defaultAddyIo", + ), + ) + + viewModel.trySendAction(action) + val expectedState = defaultAddyIoState.copy( generatedText = "-", selectedType = GeneratorState.MainType.Username( GeneratorState.MainType.Username.UsernameType.ForwardedEmailAlias( selectedServiceType = ServiceType .AddyIo( - domainName = newDomainName, + selfHostServerUrl = newSelfHostServerUrl, ), ), ), @@ -2295,6 +2335,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createPassphraseState( @@ -2314,6 +2355,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createUsernameModeState( @@ -2322,14 +2364,15 @@ class GeneratorViewModelTest : BaseViewModelTest() { ): GeneratorState = GeneratorState( generatedText = generatedText, - generatorMode = GeneratorMode.Modal.Username(website = null), selectedType = GeneratorState.MainType.Username( GeneratorState.MainType.Username.UsernameType.PlusAddressedEmail( email = email, ), ), + generatorMode = GeneratorMode.Modal.Username(website = null), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createForwardedEmailAliasState( @@ -2346,6 +2389,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createAddyIoState( @@ -2361,6 +2405,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createDuckDuckGoState( @@ -2376,6 +2421,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createFastMailState( @@ -2391,6 +2437,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createFirefoxRelayState( @@ -2406,6 +2453,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createForwardEmailState( @@ -2421,6 +2469,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createSimpleLoginState( @@ -2436,6 +2485,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createPlusAddressedEmailState( @@ -2451,6 +2501,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createCatchAllEmailState( @@ -2466,6 +2517,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createRandomWordState( @@ -2483,6 +2535,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { ), currentEmailAddress = "currentEmail", shouldShowCoachMarkTour = true, + shouldShowAnonAddySelfHostServerUrlField = true, ) private fun createSavedStateHandleWithState(state: GeneratorState) = @@ -2500,6 +2553,7 @@ class GeneratorViewModelTest : BaseViewModelTest() { policyManager = policyManager, reviewPromptManager = reviewPromptManager, firstTimeActionManager = firstTimeActionManager, + featureFlagManager = featureFlagManager, ) private fun createViewModel( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensionsTest.kt index 7c35a1e5ab..3c6ff0fb3a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ForwardedEmailServiceTypeExtensionsTest.kt @@ -22,13 +22,17 @@ class ForwardedEmailServiceTypeExtensionsTest { fastMailApiKey = "api_key_fast_mail", anonAddyApiAccessToken = "access_token_anon_addy", anonAddyDomainName = "anonaddy.com", + anonAddySelfHostServerUrl = "https://anonaddy.local", forwardEmailApiAccessToken = "access_token_forward_email", forwardEmailDomainName = "forwardemail.net", emailWebsite = "email.example.com", ) UsernameGenerationOptions.ForwardedEmailServiceType.entries .forEach { - val expected = createMockForwardedEmailAliasServiceType(it) + val expected = createMockForwardedEmailAliasServiceType( + serviceTypeOption = it, + useEmptyValues = false, + ) assertEquals( expected, it.toServiceType(options), @@ -36,39 +40,90 @@ class ForwardedEmailServiceTypeExtensionsTest { } } - @Suppress("MaxLineLength") + @Test + fun `toServiceType should map to correct service type with empty values`() { + val options = UsernameGenerationOptions( + type = UsernameGenerationOptions.UsernameType.RANDOM_WORD, + serviceType = UsernameGenerationOptions.ForwardedEmailServiceType.NONE, + capitalizeRandomWordUsername = true, + includeNumberRandomWordUsername = false, + plusAddressedEmail = null, + catchAllEmailDomain = null, + firefoxRelayApiAccessToken = null, + simpleLoginApiKey = null, + duckDuckGoApiKey = null, + fastMailApiKey = null, + anonAddyApiAccessToken = null, + anonAddyDomainName = null, + anonAddySelfHostServerUrl = null, + forwardEmailApiAccessToken = null, + forwardEmailDomainName = null, + emailWebsite = null, + ) + UsernameGenerationOptions.ForwardedEmailServiceType.entries + .forEach { + assertEquals( + createMockForwardedEmailAliasServiceType( + serviceTypeOption = it, + useEmptyValues = true, + ), + it.toServiceType(options), + ) + } + } + private fun createMockForwardedEmailAliasServiceType( serviceTypeOption: UsernameGenerationOptions.ForwardedEmailServiceType, + useEmptyValues: Boolean = false, ): ServiceType? = when (serviceTypeOption) { UsernameGenerationOptions.ForwardedEmailServiceType.NONE -> null UsernameGenerationOptions.ForwardedEmailServiceType.ANON_ADDY -> { ServiceType.AddyIo( - apiAccessToken = "access_token_anon_addy", - domainName = "anonaddy.com", + apiAccessToken = "access_token_anon_addy".takeUnless { useEmptyValues }.orEmpty(), + domainName = "anonaddy.com".takeUnless { useEmptyValues }.orEmpty(), + selfHostServerUrl = "https://anonaddy.local" + .takeUnless { useEmptyValues } + .orEmpty(), ) } UsernameGenerationOptions.ForwardedEmailServiceType.FIREFOX_RELAY -> { - ServiceType.FirefoxRelay(apiAccessToken = "access_token_firefox_relay") + ServiceType.FirefoxRelay( + apiAccessToken = "access_token_firefox_relay" + .takeUnless { useEmptyValues } + .orEmpty(), + ) } UsernameGenerationOptions.ForwardedEmailServiceType.SIMPLE_LOGIN -> { - ServiceType.SimpleLogin(apiKey = "api_key_simple_login") + ServiceType.SimpleLogin( + apiKey = "api_key_simple_login" + .takeUnless { useEmptyValues } + .orEmpty(), + ) } UsernameGenerationOptions.ForwardedEmailServiceType.DUCK_DUCK_GO -> { - ServiceType.DuckDuckGo(apiKey = "api_key_duck_duck_go") + ServiceType.DuckDuckGo( + apiKey = "api_key_duck_duck_go" + .takeUnless { useEmptyValues } + .orEmpty(), + ) } UsernameGenerationOptions.ForwardedEmailServiceType.FASTMAIL -> { - ServiceType.FastMail(apiKey = "api_key_fast_mail") + ServiceType.FastMail( + apiKey = "api_key_fast_mail" + .takeUnless { useEmptyValues } + .orEmpty(), + ) } UsernameGenerationOptions.ForwardedEmailServiceType.FORWARD_EMAIL -> { ServiceType.ForwardEmail( - apiKey = "access_token_forward_email", - domainName = "forwardemail.net", + apiKey = "access_token_forward_email".takeUnless { useEmptyValues }.orEmpty(), + domainName = "forwardemail.net".takeUnless { useEmptyValues }.orEmpty(), ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensionsTest.kt index b5f5caa58a..ee1ef22cd0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/util/ServiceTypeExtensionsTest.kt @@ -12,11 +12,13 @@ internal class ServiceTypeExtensionsTest { @Test fun `toUsernameGeneratorRequest for AddyIo returns null when apiAccessToken is blank`() { val addyIoServiceType = ServiceType.AddyIo( - apiAccessToken = "", domainName = "test.com", - baseUrl = "http://test.com", + selfHostServerUrl = "http://test.com", + ) + val request = addyIoServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, ) - val request = addyIoServiceType.toUsernameGeneratorRequest(website = null) assertNull(request) } @@ -25,23 +27,24 @@ internal class ServiceTypeExtensionsTest { fun `toUsernameGeneratorRequest for AddyIo returns null when domainName is blank`() { val addyIoServiceType = ServiceType.AddyIo( apiAccessToken = "testToken", - domainName = "", - baseUrl = "http://test.com", + selfHostServerUrl = "http://test.com", + ) + val request = addyIoServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, ) - val request = addyIoServiceType.toUsernameGeneratorRequest(website = null) assertNull(request) } @Test - fun `toUsernameGeneratorRequest for AddyIo returns correct request`() { + fun `toUsernameGeneratorRequest for AddyIo with selfHostServerUrl returns correct request`() { val addyIoServiceType = ServiceType.AddyIo( apiAccessToken = "testToken", domainName = "test.com", - baseUrl = "http://test.com", + selfHostServerUrl = "http://test.com", ) val website = "bitwarden.com" - val request = addyIoServiceType.toUsernameGeneratorRequest(website) assertEquals( UsernameGeneratorRequest.Forwarded( @@ -52,6 +55,51 @@ internal class ServiceTypeExtensionsTest { ), website = website, ), + addyIoServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ), + ) + + // Verify the correct request is returned when allowAddyIoSelfHostUrl is false + assertEquals( + UsernameGeneratorRequest.Forwarded( + service = ForwarderServiceType.AddyIo( + apiToken = "testToken", + domain = "test.com", + baseUrl = ServiceType.AddyIo.DEFAULT_ADDY_IO_URL, + ), + website = website, + ), + addyIoServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = false, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toUsernameGeneratorRequest for AddyIo without selfHostServerUrl returns correct request`() { + val addyIoServiceType = ServiceType.AddyIo( + apiAccessToken = "testToken", + domainName = "test.com", + ) + val website = "bitwarden.com" + val request = addyIoServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) + + assertEquals( + UsernameGeneratorRequest.Forwarded( + service = ForwarderServiceType.AddyIo( + apiToken = "testToken", + domain = "test.com", + baseUrl = ServiceType.AddyIo.DEFAULT_ADDY_IO_URL, + ), + website = website, + ), request, ) } @@ -59,7 +107,10 @@ internal class ServiceTypeExtensionsTest { @Test fun `toUsernameGeneratorRequest for DuckDuckGo returns null when apiKey is blank`() { val duckDuckGoServiceType = ServiceType.DuckDuckGo(apiKey = "") - val request = duckDuckGoServiceType.toUsernameGeneratorRequest(website = null) + val request = duckDuckGoServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -68,7 +119,10 @@ internal class ServiceTypeExtensionsTest { fun `toUsernameGeneratorRequest for DuckDuckGo returns correct request`() { val duckDuckGoServiceType = ServiceType.DuckDuckGo(apiKey = "testKey") val website = "bitwarden.com" - val request = duckDuckGoServiceType.toUsernameGeneratorRequest(website) + val request = duckDuckGoServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) assertEquals( UsernameGeneratorRequest.Forwarded( @@ -82,7 +136,10 @@ internal class ServiceTypeExtensionsTest { @Test fun `toUsernameGeneratorRequest for FirefoxRelay returns null when apiAccessToken is blank`() { val firefoxRelayServiceType = ServiceType.FirefoxRelay(apiAccessToken = "") - val request = firefoxRelayServiceType.toUsernameGeneratorRequest(website = null) + val request = firefoxRelayServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -91,7 +148,10 @@ internal class ServiceTypeExtensionsTest { fun `toUsernameGeneratorRequest for FirefoxRelay returns correct request`() { val firefoxRelayServiceType = ServiceType.FirefoxRelay(apiAccessToken = "testToken") val website = "bitwarden.com" - val request = firefoxRelayServiceType.toUsernameGeneratorRequest(website) + val request = firefoxRelayServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) assertEquals( UsernameGeneratorRequest.Forwarded( @@ -105,7 +165,10 @@ internal class ServiceTypeExtensionsTest { @Test fun `toUsernameGeneratorRequest for FastMail returns null when apiKey is blank`() { val fastMailServiceType = ServiceType.FastMail(apiKey = "") - val request = fastMailServiceType.toUsernameGeneratorRequest(website = null) + val request = fastMailServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -116,7 +179,10 @@ internal class ServiceTypeExtensionsTest { apiKey = "testKey", ) val website = "bitwarden.com" - val request = fastMailServiceType.toUsernameGeneratorRequest(website) + val request = fastMailServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) assertEquals( UsernameGeneratorRequest.Forwarded( @@ -133,7 +199,10 @@ internal class ServiceTypeExtensionsTest { apiKey = "", domainName = "domainName", ) - val request = forwardMailServiceType.toUsernameGeneratorRequest(website = null) + val request = forwardMailServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -144,7 +213,10 @@ internal class ServiceTypeExtensionsTest { apiKey = "apiKey", domainName = "", ) - val request = forwardMailServiceType.toUsernameGeneratorRequest(website = null) + val request = forwardMailServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -156,7 +228,10 @@ internal class ServiceTypeExtensionsTest { domainName = "domainName", ) val website = "bitwarden.com" - val request = forwardEmailServiceType.toUsernameGeneratorRequest(website) + val request = forwardEmailServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) assertEquals( UsernameGeneratorRequest.Forwarded( @@ -173,7 +248,10 @@ internal class ServiceTypeExtensionsTest { @Test fun `toUsernameGeneratorRequest for SimpleLogin returns null when apiKey is blank`() { val simpleLoginServiceType = ServiceType.SimpleLogin(apiKey = "") - val request = simpleLoginServiceType.toUsernameGeneratorRequest(website = null) + val request = simpleLoginServiceType.toUsernameGeneratorRequest( + website = null, + allowAddyIoSelfHostUrl = true, + ) assertNull(request) } @@ -182,7 +260,10 @@ internal class ServiceTypeExtensionsTest { fun `toUsernameGeneratorRequest for SimpleLogin returns correct request`() { val simpleLoginServiceType = ServiceType.SimpleLogin(apiKey = "testKey") val website = "bitwarden.com" - val request = simpleLoginServiceType.toUsernameGeneratorRequest(website) + val request = simpleLoginServiceType.toUsernameGeneratorRequest( + website = website, + allowAddyIoSelfHostUrl = true, + ) assertEquals( UsernameGeneratorRequest.Forwarded(