diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 301448130..45b5420c8 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -24,6 +24,7 @@ "go_back": "Go back", "go_forward": "Go forward", "group_by": "Group by", + "hide_secret": "Hide secret", "label": "Label", "learn_more": "Learn more", "less": "Less", @@ -43,6 +44,7 @@ "search": "Search", "send": "Send", "share": "Share", + "show_secret": "Show secret", "start": "Start", "starting": "Starting", "stop": "Stop", @@ -238,6 +240,7 @@ "profile": "Login to view your profile", "protocols": "Protocols are empty", "schema": "Connect to a GraphQL endpoint to view schema", + "secret_environments": "Secret environments are not synced to Hoppscotch hence user has to provide it's value during run-time.", "shared_requests": "Shared requests are empty", "shared_requests_logout": "Login to view your shared requests or create a new one", "subscription": "Subscriptions are empty", @@ -269,6 +272,8 @@ "quick_peek": "Environment Quick Peek", "replace_with_variable": "Replace with variable", "scope": "Scope", + "secret": "Secret", + "secret_value": "Secret value", "select": "Select environment", "set": "Set environment", "set_as_environment": "Set as environment", @@ -277,6 +282,7 @@ "updated": "Environment updated", "value": "Value", "variable": "Variable", + "variables":"Variables", "variable_list": "Variable List" }, "error": { @@ -413,6 +419,8 @@ "description": "Inspect possible errors", "environment": { "add_environment": "Add to Environment", + "add_environment_value": "Add value", + "empty_value": "Environment value is empty for the variable '{variable}' ", "not_found": "Environment variable “{environment}” not found." }, "header": { diff --git a/packages/hoppscotch-common/src/components/environments/Add.vue b/packages/hoppscotch-common/src/components/environments/Add.vue index 3d81d1116..3446d2349 100644 --- a/packages/hoppscotch-common/src/components/environments/Add.vue +++ b/packages/hoppscotch-common/src/components/environments/Add.vue @@ -21,7 +21,7 @@ {{ t("environment.value") }} - { addGlobalEnvVariable({ key: editingName.value, value: editingValue.value, + secret: false, }) toast.success(`${t("environment.updated")}`) } else if (scope.value.type === "my-environment") { addEnvironmentVariable(scope.value.index, { key: editingName.value, value: editingValue.value, + secret: false, }) toast.success(`${t("environment.updated")}`) } else { diff --git a/packages/hoppscotch-common/src/components/environments/ImportExport.vue b/packages/hoppscotch-common/src/components/environments/ImportExport.vue index d092aedbd..56c2f3869 100644 --- a/packages/hoppscotch-common/src/components/environments/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/environments/ImportExport.vue @@ -9,7 +9,7 @@ diff --git a/packages/hoppscotch-common/src/components/environments/my/Environment.vue b/packages/hoppscotch-common/src/components/environments/my/Environment.vue index 04a272442..e5f8a80ea 100644 --- a/packages/hoppscotch-common/src/components/environments/my/Environment.vue +++ b/packages/hoppscotch-common/src/components/environments/my/Environment.vue @@ -135,6 +135,8 @@ import { useToast } from "@composables/toast" import { TippyComponent } from "vue-tippy" import { HoppSmartItem } from "@hoppscotch/ui" import { exportAsJSON } from "~/helpers/import-export/export/environment" +import { useService } from "dioc/vue" +import { SecretEnvironmentService } from "~/services/secret-environment.service" const t = useI18n() const toast = useToast() @@ -150,6 +152,8 @@ const emit = defineEmits<{ const confirmRemove = ref(false) +const secretEnvironmentService = useService(SecretEnvironmentService) + const exportEnvironmentAsJSON = () => { const { environment, environmentIndex } = props exportAsJSON(environment, environmentIndex) @@ -168,6 +172,7 @@ const removeEnvironment = () => { if (props.environmentIndex === null) return if (props.environmentIndex !== "Global") { deleteEnvironment(props.environmentIndex, props.environment.id) + secretEnvironmentService.deleteSecretEnvironment(props.environment.id) } toast.success(`${t("state.deleted")}`) } diff --git a/packages/hoppscotch-common/src/components/environments/my/index.vue b/packages/hoppscotch-common/src/components/environments/my/index.vue index f066df1e4..9eaa2f00c 100644 --- a/packages/hoppscotch-common/src/components/environments/my/index.vue +++ b/packages/hoppscotch-common/src/components/environments/my/index.vue @@ -67,6 +67,7 @@ :action="action" :editing-environment-index="editingEnvironmentIndex" :editing-variable-name="editingVariableName" + :is-secret-option-selected="secretOptionSelected" @hide-modal="displayModalEdit(false)" /> ("edit") const editingEnvironmentIndex = ref(null) const editingVariableName = ref("") +const secretOptionSelected = ref(false) const displayModalAdd = (shouldDisplay: boolean) => { action.value = "new" @@ -120,18 +122,23 @@ const editEnvironment = (environmentIndex: number) => { } const resetSelectedData = () => { editingEnvironmentIndex.value = null + editingVariableName.value = "" + secretOptionSelected.value = false } defineActionHandler( "modals.my.environment.edit", - ({ envName, variableName }) => { + ({ envName, variableName, isSecret }) => { if (variableName) editingVariableName.value = variableName const envIndex: number = environments.value.findIndex( (environment: Environment) => { return environment.name === envName } ) - if (envName !== "Global") editEnvironment(envIndex) + if (envName !== "Global") { + editEnvironment(envIndex) + secretOptionSelected.value = isSecret ?? false + } } ) diff --git a/packages/hoppscotch-common/src/components/environments/teams/Details.vue b/packages/hoppscotch-common/src/components/environments/teams/Details.vue index d54d18034..906a7a608 100644 --- a/packages/hoppscotch-common/src/components/environments/teams/Details.vue +++ b/packages/hoppscotch-common/src/components/environments/teams/Details.vue @@ -16,90 +16,112 @@ @submit="saveEnvironment" /> - - - {{ t("environment.variable_list") }} - - - - - - - - {{ t("environment.nested_overflow") }} - - + - - - - - + {{ t("environment.nested_overflow") }} - - - - + + + + + + + - + + + + + + + + + + + + + + + + + + + + + - + diff --git a/packages/hoppscotch-common/src/components/http/CodegenModal.vue b/packages/hoppscotch-common/src/components/http/CodegenModal.vue index a793dbef1..6747a1c13 100644 --- a/packages/hoppscotch-common/src/components/http/CodegenModal.vue +++ b/packages/hoppscotch-common/src/components/http/CodegenModal.vue @@ -187,6 +187,8 @@ const copyCodeIcon = refAutoReset( const requestCode = computed(() => { const aggregateEnvs = getAggregateEnvs() const env: Environment = { + v: 1, + id: "env", name: "Env", variables: aggregateEnvs, } diff --git a/packages/hoppscotch-common/src/components/http/Headers.vue b/packages/hoppscotch-common/src/components/http/Headers.vue index 1bf7effd8..49ec74461 100644 --- a/packages/hoppscotch-common/src/components/http/Headers.vue +++ b/packages/hoppscotch-common/src/components/http/Headers.vue @@ -553,7 +553,7 @@ const clearContent = () => { const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs()) const computedHeaders = computed(() => - getComputedHeaders(request.value, aggregateEnvs.value).map( + getComputedHeaders(request.value, aggregateEnvs.value, false).map( (header, index) => ({ id: `header-${index}`, ...header, @@ -606,7 +606,8 @@ const inheritedProperties = computed(() => { const computedAuthHeader = getComputedAuthHeaders( aggregateEnvs.value, request.value, - props.inheritedProperties.auth.inheritedAuth + props.inheritedProperties.auth.inheritedAuth, + false )[0] if ( diff --git a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue index ee9d2f1c0..b2aca464f 100644 --- a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue @@ -112,7 +112,7 @@ const handleAccessTokenRequest = async () => { } const envs = getCombinedEnvVariables() - const envVars = [...envs.selected, ...envs.global] + const envVars = [...envs.selected.variables, ...envs.global] try { const tokenReqParams = { diff --git a/packages/hoppscotch-common/src/components/http/TabHead.vue b/packages/hoppscotch-common/src/components/http/TabHead.vue index 22ff669a8..5f90c7056 100644 --- a/packages/hoppscotch-common/src/components/http/TabHead.vue +++ b/packages/hoppscotch-common/src/components/http/TabHead.vue @@ -64,7 +64,6 @@ :icon="IconShare2" :label="t('tab.share_tab_request')" :shortcut="['S']" - :new="true" @click=" () => { emit('share-tab-request') diff --git a/packages/hoppscotch-common/src/components/http/TestResult.vue b/packages/hoppscotch-common/src/components/http/TestResult.vue index e87ecf286..2d5aaa2a6 100644 --- a/packages/hoppscotch-common/src/components/http/TestResult.vue +++ b/packages/hoppscotch-common/src/components/http/TestResult.vue @@ -211,7 +211,6 @@ import { useI18n } from "@composables/i18n" import { globalEnv$, selectedEnvironmentIndex$, - setGlobalEnvVariables, setSelectedEnvironmentIndex, } from "~/newstore/environments" import { HoppTestResult } from "~/helpers/types/HoppTestResult" @@ -225,6 +224,7 @@ import { useColorMode } from "~/composables/theming" import { useVModel } from "@vueuse/core" import { useService } from "dioc/vue" import { WorkspaceService } from "~/services/workspace.service" +import { invokeAction } from "~/helpers/actions" const props = defineProps<{ modelValue: HoppTestResult | null | undefined @@ -304,9 +304,10 @@ const globalHasAdditions = computed(() => { const addEnvToGlobal = () => { if (!testResults.value?.envDiff.selected.additions) return - setGlobalEnvVariables([ - ...globalEnvVars.value, - ...testResults.value.envDiff.selected.additions, - ]) + + invokeAction("modals.global.environment.update", { + variables: testResults.value.envDiff.selected.additions, + isSecret: false, + }) } diff --git a/packages/hoppscotch-common/src/components/smart/EnvInput.vue b/packages/hoppscotch-common/src/components/smart/EnvInput.vue index f2a899f4f..72be1bb01 100644 --- a/packages/hoppscotch-common/src/components/smart/EnvInput.vue +++ b/packages/hoppscotch-common/src/components/smart/EnvInput.vue @@ -3,7 +3,18 @@ + + /> + (), { modelValue: "", @@ -93,6 +123,7 @@ const props = withDefaults( inspectionResult: undefined, inspectionResults: undefined, contextMenuEnabled: true, + secret: false, } ) @@ -118,10 +149,27 @@ const showSuggestionPopover = ref(false) const suggestionsMenu = ref(null) const autoCompleteWrapper = ref(null) +const isSecret = ref(props.secret) + +const secretText = ref(props.modelValue) + +watch( + () => secretText.value, + (newVal) => { + if (isSecret.value) { + updateModelValue(newVal) + } + } +) + onClickOutside(autoCompleteWrapper, () => { showSuggestionPopover.value = false }) +const toggleSecret = () => { + isSecret.value = !isSecret.value +} + //filter autocompleteSource with unique values const uniqueAutoCompleteSource = computed(() => { if (props.autoCompleteSource) { @@ -169,8 +217,6 @@ watch( ) const handleKeystroke = (ev: KeyboardEvent) => { - if (!props.autoCompleteSource) return - if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) { ev.preventDefault() } @@ -307,19 +353,28 @@ watch( let clipboardEv: ClipboardEvent | null = null let pastedValue: string | null = null -const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref< +const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref< AggregateEnvironment[] > -const envVars = computed(() => - props.envs - ? props.envs.map((x) => ({ - key: x.key, - value: x.value, - sourceEnv: x.source, - })) +const envVars = computed(() => { + return props.envs + ? props.envs.map((x) => { + if (x.secret) { + return { + key: x.key, + sourceEnv: "source" in x ? x.source : null, + value: "********", + } + } + return { + key: x.key, + value: x.value, + sourceEnv: "source" in x ? x.source : null, + } + }) : aggregateEnvs.value -) +}) const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) @@ -363,17 +418,28 @@ const initView = (el: any) => { el.addEventListener("keyup", debounceFn) } + const extensions: Extension = getExtensions(props.readonly || isSecret.value) + view.value = new EditorView({ + parent: el, + state: EditorState.create({ + doc: props.modelValue, + extensions, + }), + }) +} + +const getExtensions = (readonly: boolean): Extension => { const extensions: Extension = [ EditorView.contentAttributes.of({ "aria-label": props.placeholder }), EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }), EditorView.updateListener.of((update) => { - if (props.readonly) { + if (readonly) { update.view.contentDOM.inputMode = "none" } }), - EditorState.changeFilter.of(() => !props.readonly), + EditorState.changeFilter.of(() => !readonly), inputTheme, - props.readonly + readonly ? EditorView.theme({ ".cm-content": { caretColor: "var(--secondary-dark-color)", @@ -384,6 +450,7 @@ const initView = (el: any) => { }) : EditorView.theme({}), tooltips({ + parent: document.body, position: "absolute", }), props.environmentHighlights ? envTooltipPlugin : [], @@ -405,7 +472,8 @@ const initView = (el: any) => { ViewPlugin.fromClass( class { update(update: ViewUpdate) { - if (props.readonly) return + if (readonly) return + if (update.docChanged) { const prevValue = clone(cachedValue.value) @@ -454,14 +522,7 @@ const initView = (el: any) => { history(), keymap.of([...historyKeymap]), ] - - view.value = new EditorView({ - parent: el, - state: EditorState.create({ - doc: props.modelValue, - extensions, - }), - }) + return extensions } const triggerTextSelection = () => { @@ -474,11 +535,11 @@ const triggerTextSelection = () => { }) }) } - onMounted(() => { if (editor.value) { if (!view.value) initView(editor.value) if (props.selectTextOnMount) triggerTextSelection() + if (props.focus) view.value?.focus() platform.ui?.onCodemirrorInstanceMount?.(editor.value) } }) diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index 5de426101..58f69e06a 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -4,6 +4,7 @@ import { ViewPlugin, ViewUpdate, placeholder, + tooltips, } from "@codemirror/view" import { Extension, @@ -269,6 +270,7 @@ export function useCodemirror( basicSetup, baseTheme, syntaxHighlighting(baseHighlightStyle, { fallback: true }), + ViewPlugin.fromClass( class { update(update: ViewUpdate) { @@ -318,6 +320,7 @@ export function useCodemirror( } } ), + EditorView.domEventHandlers({ scroll(event) { if (event.target && options.contextMenuEnabled) { @@ -359,6 +362,10 @@ export function useCodemirror( run: indentLess, }, ]), + tooltips({ + parent: document.body, + position: "absolute", + }), EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }), additionalExts.of(options.additionalExts ?? []), ] diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index a653226f6..0ca299051 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -30,6 +30,13 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse" import { HoppTestData, HoppTestResult } from "./types/HoppTestResult" import { getEffectiveRESTRequest } from "./utils/EffectiveURL" import { isJSONContentType } from "./utils/contenttypes" +import { + SecretEnvironmentService, + SecretVariable, +} from "~/services/secret-environment.service" +import { getService } from "~/modules/dioc" + +const secretEnvironmentService = getService(SecretEnvironmentService) const getTestableBody = ( res: HoppRESTResponse & { type: "success" | "fail" } @@ -58,15 +65,63 @@ const getTestableBody = ( return x } -const combineEnvVariables = (env: { +const combineEnvVariables = (envs: { global: Environment["variables"] selected: Environment["variables"] -}) => [...env.selected, ...env.global] +}) => [...envs.selected, ...envs.global] export const executedResponses$ = new Subject< HoppRESTResponse & { type: "success" | "fail " } >() +/** + * Used to update the environment schema with the secret variables + * and store the secret variable values in the secret environment service + * @param envs The environment variables to update + * @param type Whether the environment variables are global or selected + * @returns the updated environment variables + */ +const updateEnvironmentsWithSecret = ( + envs: Environment["variables"] & + { + secret: true + value: string | undefined + key: string + }[], + type: "global" | "selected" +) => { + const currentEnvID = + type === "selected" ? getCurrentEnvironment().id : "Global" + + const updatedSecretEnvironments: SecretVariable[] = [] + + const updatedEnv = pipe( + envs, + A.mapWithIndex((index, e) => { + if (e.secret) { + updatedSecretEnvironments.push({ + key: e.key, + value: e.value ?? "", + varIndex: index, + }) + + // delete the value from the environment + // so that it doesn't get saved in the environment + delete e.value + return e + } + return e + }) + ) + if (currentEnvID) { + secretEnvironmentService.addSecretEnvironment( + currentEnvID, + updatedSecretEnvironments + ) + } + return updatedEnv +} + export function runRESTRequest$( tab: Ref> ): [ @@ -154,15 +209,36 @@ export function runRESTRequest$( ) if (E.isRight(runResult)) { + const updatedGlobalEnvVariables = updateEnvironmentsWithSecret( + cloneDeep(runResult.right.envs.global), + "global" + ) + + const updatedSelectedEnvVariables = updateEnvironmentsWithSecret( + cloneDeep(runResult.right.envs.selected), + "selected" + ) + // set the response in the tab so that multiple tabs can run request simultaneously tab.value.document.response = res - tab.value.document.testResults = translateToSandboxTestResults( - runResult.right + const updatedRunResult = { + ...runResult.right, + envs: { + global: updatedGlobalEnvVariables, + selected: updatedSelectedEnvVariables, + }, + } + + tab.value.document.testResults = + translateToSandboxTestResults(updatedRunResult) + + setGlobalEnvVariables( + updateEnvironmentsWithSecret( + runResult.right.envs.global, + "global" + ) ) - - setGlobalEnvVariables(runResult.right.envs.global) - if ( environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV" ) { @@ -173,8 +249,10 @@ export function runRESTRequest$( updateEnvironment( environmentsStore.value.selectedEnvironmentIndex.index, { - ...env, - variables: runResult.right.envs.selected, + name: env.name, + v: 1, + id: env.id ?? "", + variables: updatedRunResult.envs.selected, } ) } else if ( @@ -186,7 +264,7 @@ export function runRESTRequest$( }) pipe( updateTeamEnvironment( - JSON.stringify(runResult.right.envs.selected), + JSON.stringify(updatedRunResult.envs.selected), environmentsStore.value.selectedEnvironmentIndex.teamEnvID, env.name ) @@ -275,7 +353,6 @@ function translateToSandboxTestResults( const globals = cloneDeep(getGlobalVariables()) const env = getCurrentEnvironment() - return { description: "", expectResults: testDesc.tests.expectResults, diff --git a/packages/hoppscotch-common/src/helpers/actions.ts b/packages/hoppscotch-common/src/helpers/actions.ts index 05fd1909b..cef62d130 100644 --- a/packages/hoppscotch-common/src/helpers/actions.ts +++ b/packages/hoppscotch-common/src/helpers/actions.ts @@ -5,7 +5,7 @@ import { Ref, onBeforeUnmount, onMounted, reactive, watch } from "vue" import { BehaviorSubject } from "rxjs" import { HoppRESTDocument } from "./rest/document" -import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data" +import { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data" import { RESTOptionTabs } from "~/components/http/RequestOptions.vue" import { HoppGQLSaveContext } from "./graphql/document" import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" @@ -43,6 +43,7 @@ export type HoppAction = | "modals.environment.new" // Add new environment | "modals.environment.delete-selected" // Delete Selected Environment | "modals.my.environment.edit" // Edit current personal environment + | "modals.global.environment.update" // Update global environment | "modals.team.environment.edit" // Edit current team environment | "modals.team.new" // Add new team | "modals.team.edit" // Edit selected team @@ -93,13 +94,19 @@ type HoppActionArgsMap = { } text: string | null } + "modals.global.environment.update": { + variables?: Environment["variables"] + isSecret?: boolean + } "modals.my.environment.edit": { envName: string variableName?: string + isSecret?: boolean } "modals.team.environment.edit": { envName: string variableName?: string + isSecret?: boolean } "modals.team.delete": { teamId: string diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql index efaba9855..2e72455b9 100644 --- a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateTeamEnvironment.graphql @@ -1,7 +1,12 @@ -mutation CreateTeamEnvironment($variables: String!,$teamID: ID!,$name: String!){ - createTeamEnvironment( variables: $variables ,teamID: $teamID ,name: $name){ +mutation CreateTeamEnvironment( + $variables: String! + $teamID: ID! + $name: String! +) { + createTeamEnvironment(variables: $variables, teamID: $teamID, name: $name) { variables name teamID + id } -} \ No newline at end of file +} diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts index 8c6fda20d..c949595e5 100644 --- a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts @@ -12,8 +12,8 @@ import { parseTemplateStringE } from "@hoppscotch/data" import { StreamSubscriberFunc } from "@composables/stream" import { AggregateEnvironment, - aggregateEnvs$, - getAggregateEnvs, + aggregateEnvsWithSecrets$, + getAggregateEnvsWithSecrets, getSelectedEnvironmentType, } from "~/newstore/environments" import { invokeAction } from "~/helpers/actions" @@ -66,7 +66,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment" - const envValue = tooltipEnv?.value ?? "Not found" + let envValue = "Not Found" + + if (!tooltipEnv?.secret && tooltipEnv?.value) envValue = tooltipEnv.value + else if (tooltipEnv?.secret && tooltipEnv.value) { + envValue = "******" + } else if (!tooltipEnv?.sourceEnv) { + envValue = "Not Found" + } else if (!tooltipEnv?.value) { + envValue = "Empty" + } const result = parseTemplateStringE(envValue, aggregateEnvs) @@ -89,6 +98,7 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => invokeAction(`modals.${action}.environment.edit`, { envName, variableName: parsedEnvKey, + isSecret: tooltipEnv?.secret, }) }) editIcon.innerHTML = `${IconEdit}` @@ -171,9 +181,9 @@ export class HoppEnvironmentPlugin { subscribeToStream: StreamSubscriberFunc, private editorView: Ref ) { - this.envs = getAggregateEnvs() + this.envs = getAggregateEnvsWithSecrets() - subscribeToStream(aggregateEnvs$, (envs) => { + subscribeToStream(aggregateEnvsWithSecrets$, (envs) => { this.envs = envs this.editorView.value?.dispatch({ diff --git a/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts index 82c812e57..22b6dff58 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/export/environment.ts @@ -12,8 +12,6 @@ const getEnvironmentJson = ( ? cloneDeep(environmentObj.environment) : cloneDeep(environmentObj) - delete newEnvironment.id - const environmentId = environmentIndex || environmentIndex === 0 ? environmentIndex diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts index bf58fc301..e4d8c6efa 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/hoppEnv.ts @@ -1,22 +1,13 @@ -import * as TE from "fp-ts/TaskEither" import * as O from "fp-ts/Option" +import * as TE from "fp-ts/TaskEither" +import { entityReference } from "verzod" -import { IMPORTER_INVALID_FILE_FORMAT } from "." import { safeParseJSON } from "~/helpers/functional/json" +import { IMPORTER_INVALID_FILE_FORMAT } from "." +import { Environment } from "@hoppscotch/data" import { z } from "zod" -const hoppEnvSchema = z.object({ - id: z.string().optional(), - name: z.string(), - variables: z.array( - z.object({ - key: z.string(), - value: z.string(), - }) - ), -}) - export const hoppEnvImporter = (content: string) => { const parsedContent = safeParseJSON(content, true) @@ -25,7 +16,9 @@ export const hoppEnvImporter = (content: string) => { return TE.left(IMPORTER_INVALID_FILE_FORMAT) } - const validationResult = z.array(hoppEnvSchema).safeParse(parsedContent.value) + const validationResult = z + .array(entityReference(Environment)) + .safeParse(parsedContent.value) if (!validationResult.success) { return TE.left(IMPORTER_INVALID_FILE_FORMAT) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomniaEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomniaEnv.ts index 19c743109..ba3ce36c2 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomniaEnv.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomniaEnv.ts @@ -4,8 +4,9 @@ import * as O from "fp-ts/Option" import { IMPORTER_INVALID_FILE_FORMAT } from "." import { z } from "zod" -import { Environment } from "@hoppscotch/data" +import { NonSecretEnvironment } from "@hoppscotch/data" import { safeParseJSONOrYAML } from "~/helpers/functional/yaml" +import { uniqueId } from "lodash-es" const insomniaResourcesSchema = z.object({ resources: z.array( @@ -56,16 +57,18 @@ export const insomniaEnvImporter = (content: string) => { return { ...envResource, data: stringifiedData } }) - const environments: Environment[] = [] + const environments: NonSecretEnvironment[] = [] insomniaEnvs.forEach((insomniaEnv) => { const parsedInsomniaEnv = insomniaEnvSchema.safeParse(insomniaEnv) if (parsedInsomniaEnv.success) { - const environment: Environment = { + const environment: NonSecretEnvironment = { + id: uniqueId(), + v: 1, name: parsedInsomniaEnv.data.name, variables: Object.entries(parsedInsomniaEnv.data.data).map( - ([key, value]) => ({ key, value }) + ([key, value]) => ({ key, value, secret: false }) ), } diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts index 2978a501e..428db3c6d 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postmanEnv.ts @@ -6,6 +6,7 @@ import { safeParseJSON } from "~/helpers/functional/json" import { z } from "zod" import { Environment } from "@hoppscotch/data" +import { uniqueId } from "lodash-es" const postmanEnvSchema = z.object({ name: z.string(), @@ -34,12 +35,14 @@ export const postmanEnvImporter = (content: string) => { const postmanEnv = validationResult.data const environment: Environment = { + id: uniqueId(), + v: 1, name: postmanEnv.name, variables: [], } postmanEnv.values.forEach(({ key, value }) => - environment.variables.push({ key, value }) + environment.variables.push({ key, value, secret: false }) ) return TE.right(environment) diff --git a/packages/hoppscotch-common/src/helpers/preRequest.ts b/packages/hoppscotch-common/src/helpers/preRequest.ts index 07b96bf8c..562ffaefc 100644 --- a/packages/hoppscotch-common/src/helpers/preRequest.ts +++ b/packages/hoppscotch-common/src/helpers/preRequest.ts @@ -8,11 +8,71 @@ import { getGlobalVariables, } from "~/newstore/environments" import { TestResult } from "@hoppscotch/js-sandbox" +import { getService } from "~/modules/dioc" +import { SecretEnvironmentService } from "~/services/secret-environment.service" -export const getCombinedEnvVariables = () => ({ - global: cloneDeep(getGlobalVariables()), - selected: cloneDeep(getCurrentEnvironment().variables), -}) +const secretEnvironmentService = getService(SecretEnvironmentService) + +const unsecretEnvironments = ( + global: Environment["variables"], + selected: Environment +) => { + const resolvedGlobalWithSecrets = global.map((globalVar, index) => { + const secretVar = secretEnvironmentService.getSecretEnvironmentVariable( + "Global", + index + ) + if (secretVar) { + return { + ...globalVar, + value: secretVar.value, + } + } else if (!("value" in globalVar) || !globalVar.value) { + return { + ...globalVar, + value: "", + } + } + return globalVar + }) + + const resolvedSelectedWithSecrets = selected.variables.map( + (selectedVar, index) => { + const secretVar = secretEnvironmentService.getSecretEnvironmentVariable( + selected.id, + index + ) + if (secretVar) { + return { + ...selectedVar, + value: secretVar.value, + } + } else if (!("value" in selectedVar) || !selectedVar.value) { + return { + ...selectedVar, + value: "", + } + } + return selectedVar + } + ) + + return { + global: resolvedGlobalWithSecrets, + selected: resolvedSelectedWithSecrets, + } +} + +export const getCombinedEnvVariables = () => { + const reformedVars = unsecretEnvironments( + getGlobalVariables(), + getCurrentEnvironment() + ) + return { + global: cloneDeep(reformedVars.global), + selected: cloneDeep(reformedVars.selected), + } +} export const getFinalEnvsFromPreRequest = ( script: string, diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts index e8aa29fe7..98bb0dec9 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamEnvironmentAdapter.ts @@ -118,6 +118,8 @@ export default class TeamEnvironmentAdapter { id: x.id, teamID: x.teamID, environment: { + v: 1, + id: x.id, name: x.name, variables: JSON.parse(x.variables), }, @@ -196,6 +198,8 @@ export default class TeamEnvironmentAdapter { id: x.id, teamID: x.teamID, environment: { + v: 1, + id: x.id, name: x.name, variables: JSON.parse(x.variables), }, @@ -249,6 +253,8 @@ export default class TeamEnvironmentAdapter { id: x.id, teamID: x.teamID, environment: { + v: 1, + id: x.id, name: x.name, variables: JSON.parse(x.variables), }, diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index 97b447da3..d287976ee 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -45,7 +45,8 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest { export const getComputedAuthHeaders = ( envVars: Environment["variables"], req?: HoppRESTRequest, - auth?: HoppRESTRequest["auth"] + auth?: HoppRESTRequest["auth"], + parse = true ) => { const request = auth ? { auth: auth ?? { authActive: false } } : req // If Authorization header is also being user-defined, that takes priority @@ -60,8 +61,12 @@ export const getComputedAuthHeaders = ( // TODO: Support a better b64 implementation than btoa ? if (request.auth.authType === "basic") { - const username = parseTemplateString(request.auth.username, envVars) - const password = parseTemplateString(request.auth.password, envVars) + const username = parse + ? parseTemplateString(request.auth.username, envVars) + : request.auth.username + const password = parse + ? parseTemplateString(request.auth.password, envVars) + : request.auth.password headers.push({ active: true, @@ -75,7 +80,11 @@ export const getComputedAuthHeaders = ( headers.push({ active: true, key: "Authorization", - value: `Bearer ${parseTemplateString(request.auth.token, envVars)}`, + value: `Bearer ${ + parse + ? parseTemplateString(request.auth.token, envVars) + : request.auth.token + }`, }) } else if (request.auth.authType === "api-key") { const { key, addTo } = request.auth @@ -83,7 +92,9 @@ export const getComputedAuthHeaders = ( headers.push({ active: true, key: parseTemplateString(key, envVars), - value: parseTemplateString(request.auth.value ?? "", envVars), + value: parse + ? parseTemplateString(request.auth.value ?? "", envVars) + : request.auth.value ?? "", }) } } @@ -133,10 +144,11 @@ export type ComputedHeader = { */ export const getComputedHeaders = ( req: HoppRESTRequest, - envVars: Environment["variables"] + envVars: Environment["variables"], + parse = true ): ComputedHeader[] => { return [ - ...getComputedAuthHeaders(envVars, req).map((header) => ({ + ...getComputedAuthHeaders(envVars, req, undefined, parse).map((header) => ({ source: "auth" as const, header, })), diff --git a/packages/hoppscotch-common/src/newstore/environments.ts b/packages/hoppscotch-common/src/newstore/environments.ts index e92c3eadf..4659c8144 100644 --- a/packages/hoppscotch-common/src/newstore/environments.ts +++ b/packages/hoppscotch-common/src/newstore/environments.ts @@ -1,10 +1,12 @@ import { Environment } from "@hoppscotch/data" -import { cloneDeep, isEqual } from "lodash-es" +import { cloneDeep, isEqual, uniqueId } from "lodash-es" import { combineLatest, Observable } from "rxjs" import { distinctUntilChanged, map, pluck } from "rxjs/operators" +import { getService } from "~/modules/dioc" import DispatchingStore, { defineDispatchers, } from "~/newstore/DispatchingStore" +import { SecretEnvironmentService } from "~/services/secret-environment.service" export type SelectedEnvironmentIndex = | { type: "NO_ENV_SELECTED" } @@ -19,6 +21,8 @@ export type SelectedEnvironmentIndex = const defaultEnvironmentsState = { environments: [ { + v: 1, + id: uniqueId(), name: "My Environment Variables", variables: [], }, @@ -33,6 +37,8 @@ const defaultEnvironmentsState = { } as SelectedEnvironmentIndex, } +const secretEnvironmentService = getService(SecretEnvironmentService) + type EnvironmentStore = typeof defaultEnvironmentsState const dispatchers = defineDispatchers({ @@ -88,10 +94,13 @@ const dispatchers = defineDispatchers({ envID ? { id: envID, + v: 1, name, variables, } : { + v: 1, + id: "", name, variables, }, @@ -109,14 +118,12 @@ const dispatchers = defineDispatchers({ } } - // remove the id, because this is a new environment & it will get its own id when syncing - delete newEnvironment["id"] - return { environments: [ ...environments, { ...cloneDeep(newEnvironment), + id: uniqueId(), name: `${newEnvironment.name} - Duplicate`, }, ], @@ -184,14 +191,19 @@ const dispatchers = defineDispatchers({ }, addEnvironmentVariable( { environments }: EnvironmentStore, - { envIndex, key, value }: { envIndex: number; key: string; value: string } + { + envIndex, + key, + value, + secret, + }: { envIndex: number; key: string; value: string; secret: boolean } ) { return { environments: environments.map((env, index) => index === envIndex ? { ...env, - variables: [...env.variables, { key, value }], + variables: [...env.variables, { key, value, secret }], } : env ), @@ -219,7 +231,10 @@ const dispatchers = defineDispatchers({ { envIndex, vars, - }: { envIndex: number; vars: { key: string; value: string }[] } + }: { + envIndex: number + vars: { key: string; value: string; secret: boolean }[] + } ) { return { environments: environments.map((env, index) => @@ -253,7 +268,7 @@ const dispatchers = defineDispatchers({ ...env, variables: env.variables.map((v, vIndex) => vIndex === variableIndex - ? { key: updatedKey, value: updatedValue } + ? { key: updatedKey, value: updatedValue, secret: v.secret } : v ), } @@ -343,6 +358,8 @@ export const currentEnvironment$: Observable = if (selectedEnvironmentIndex.type === "NO_ENV_SELECTED") { const env: Environment = { name: "No environment", + v: 1, + id: "", variables: [], } return env @@ -356,6 +373,7 @@ export const currentEnvironment$: Observable = export type AggregateEnvironment = { key: string value: string + secret: boolean sourceEnv: string } @@ -370,11 +388,11 @@ export const aggregateEnvs$: Observable = combineLatest( map(([selectedEnv, globalVars]) => { const results: AggregateEnvironment[] = [] - selectedEnv?.variables.forEach(({ key, value }) => - results.push({ key, value, sourceEnv: selectedEnv.name }) + selectedEnv?.variables.forEach(({ key, value, secret }) => + results.push({ key, value, secret, sourceEnv: selectedEnv.name }) ) - globalVars.forEach(({ key, value }) => - results.push({ key, value, sourceEnv: "Global" }) + globalVars.forEach(({ key, value, secret }) => + results.push({ key, value, secret, sourceEnv: "Global" }) ) return results @@ -384,32 +402,129 @@ export const aggregateEnvs$: Observable = combineLatest( export function getAggregateEnvs() { const currentEnv = getCurrentEnvironment() - return [ - ...currentEnv.variables.map( - (x) => - { - key: x.key, - value: x.value, - sourceEnv: currentEnv.name, - } - ), - ...getGlobalVariables().map( - (x) => - { - key: x.key, - value: x.value, - sourceEnv: "Global", - } - ), + ...currentEnv.variables.map((x) => { + let value + if (!x.secret) { + value = x.value + } + + return { + key: x.key, + value, + secret: x.secret, + sourceEnv: currentEnv.name, + } + }), + ...getGlobalVariables().map((x) => { + let value + if (!x.secret) { + value = x.value + } + return { + key: x.key, + value, + secret: x.secret, + sourceEnv: "Global", + } + }), ] } +export function getAggregateEnvsWithSecrets() { + const currentEnv = getCurrentEnvironment() + return [ + ...currentEnv.variables.map((x, index) => { + let value + if (x.secret) { + value = secretEnvironmentService.getSecretEnvironmentVariableValue( + currentEnv.id, + index + ) + } else { + value = x.value + } + + return { + key: x.key, + value, + secret: x.secret, + sourceEnv: currentEnv.name, + } + }), + ...getGlobalVariables().map((x, index) => { + let value + if (x.secret) { + value = secretEnvironmentService.getSecretEnvironmentVariableValue( + "Global", + index + ) + } else { + value = x.value + } + return { + key: x.key, + value, + secret: x.secret, + sourceEnv: "Global", + } + }), + ] +} + +export const aggregateEnvsWithSecrets$: Observable = + combineLatest([currentEnvironment$, globalEnv$]).pipe( + map(([selectedEnv, globalVars]) => { + const results: AggregateEnvironment[] = [] + + selectedEnv?.variables.map((x, index) => { + let value + if (x.secret) { + value = secretEnvironmentService.getSecretEnvironmentVariableValue( + selectedEnv.id, + index + ) + } else { + value = x.value + } + results.push({ + key: x.key, + value: value ?? "", + secret: x.secret, + sourceEnv: selectedEnv.name, + }) + }) + + globalVars.map((x, index) => { + let value + if (x.secret) { + value = secretEnvironmentService.getSecretEnvironmentVariableValue( + "Global", + index + ) + } else { + value = x.value + } + results.push({ + key: x.key, + value: value ?? "", + secret: x.secret, + sourceEnv: "Global", + }) + }) + + return results + }), + distinctUntilChanged(isEqual) + ) + export function getCurrentEnvironment(): Environment { if ( environmentsStore.value.selectedEnvironmentIndex.type === "NO_ENV_SELECTED" ) { return { + v: 1, + id: "", name: "No environment", variables: [], } @@ -589,7 +704,7 @@ export function updateEnvironment(envIndex: number, updatedEnv: Environment) { export function setEnvironmentVariables( envIndex: number, - vars: { key: string; value: string }[] + vars: { key: string; value: string; secret: boolean }[] ) { environmentsStore.dispatch({ dispatcher: "setEnvironmentVariables", @@ -602,7 +717,7 @@ export function setEnvironmentVariables( export function addEnvironmentVariable( envIndex: number, - { key, value }: { key: string; value: string } + { key, value, secret }: { key: string; value: string; secret: boolean } ) { environmentsStore.dispatch({ dispatcher: "addEnvironmentVariable", @@ -610,6 +725,7 @@ export function addEnvironmentVariable( envIndex, key, value, + secret, }, }) } diff --git a/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts new file mode 100644 index 000000000..a39589576 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/secret-environment.service.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { SecretEnvironmentService } from "../secret-environment.service" + +describe("SecretEnvironmentService", () => { + let container: TestContainer + let service: SecretEnvironmentService + + beforeEach(() => { + container = new TestContainer() + service = container.bind(SecretEnvironmentService) + }) + + describe("addSecretEnvironment", () => { + it("should add a new secret environment with the provided ID and secret variables", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.addSecretEnvironment(id, secretVars) + + expect(service.secretEnvironments.get(id)).toEqual(secretVars) + }) + }) + + describe("getSecretEnvironment", () => { + it("should return the secret variables of the specified environment", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + expect(service.getSecretEnvironment(id)).toEqual(secretVars) + }) + + it("should return undefined if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + expect(service.getSecretEnvironment(id)).toBeUndefined() + }) + }) + + describe("getSecretEnvironmentVariable", () => { + it("should return the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariable(id, 1) + + expect(result).toEqual(secretVars[0]) + }) + + it("should return undefined if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariable(id, 2) + + expect(result).toBeUndefined() + }) + + it("should return undefined if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + const result = service.getSecretEnvironmentVariable(id, 1) + + expect(result).toBeUndefined() + }) + }) + + describe("getSecretEnvironmentVariableValue", () => { + it("should return the value of the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariableValue(id, 1) + + expect(result).toEqual(secretVars[0].value) + }) + + it("should return undefined if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + const result = service.getSecretEnvironmentVariableValue(id, 2) + + expect(result).toBeUndefined() + }) + + it("should return undefined if the specified environment does not exist", () => { + const id = "nonExistentEnvironment" + + const result = service.getSecretEnvironmentVariableValue(id, 1) + + expect(result).toBeUndefined() + }) + }) + + describe("loadSecretEnvironmentsFromPersistedState", () => { + it("should load secret environments from the persisted state", () => { + const persistedState = { + testEnvironment: [{ key: "key1", value: "value1", varIndex: 1 }], + } + + service.loadSecretEnvironmentsFromPersistedState(persistedState) + + expect(service.secretEnvironments.size).toBe(1) + expect(service.secretEnvironments.get("testEnvironment")).toEqual([ + { key: "key1", value: "value1", varIndex: 1 }, + ]) + }) + }) + + describe("deleteSecretEnvironment", () => { + it("should delete the specified secret environment", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + service.deleteSecretEnvironment(id) + + expect(service.secretEnvironments.has(id)).toBe(false) + }) + }) + + describe("removeSecretEnvironmentVariable", () => { + it("should remove the specified secret environment variable", () => { + const id = "testEnvironment" + const secretVars = [ + { key: "key1", value: "value1", varIndex: 1 }, + { key: "key2", value: "value2", varIndex: 2 }, + ] + + service.secretEnvironments.set(id, secretVars) + + service.removeSecretEnvironmentVariable(id, 1) + + expect(service.secretEnvironments.get(id)).toEqual([ + { key: "key2", value: "value2", varIndex: 2 }, + ]) + }) + + it("should do nothing if the specified variable does not exist", () => { + const id = "testEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(id, secretVars) + + service.removeSecretEnvironmentVariable(id, 2) + + expect(service.secretEnvironments.get(id)).toEqual(secretVars) + }) + }) + + describe("updateSecretEnvironmentID", () => { + it("should update the ID of the specified secret environment", () => { + const oldID = "oldEnvironment" + const newID = "newEnvironment" + const secretVars = [{ key: "key1", value: "value1", varIndex: 1 }] + + service.secretEnvironments.set(oldID, secretVars) + + service.updateSecretEnvironmentID(oldID, newID) + + expect(service.secretEnvironments.has(oldID)).toBe(false) + expect(service.secretEnvironments.get(newID)).toEqual(secretVars) + }) + }) + + describe("persistableSecretEnvironments", () => { + it("should return a record of secret environments suitable for persistence", () => { + const secretVars = [ + { key: "key1", value: "value1", varIndex: 1 }, + { key: "key2", value: "value2", varIndex: 2 }, + ] + + service.secretEnvironments.set("environment1", secretVars) + + const persistedState = service.persistableSecretEnvironments.value + + expect(persistedState).toEqual({ + environment1: secretVars, + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts index dd7cb8c88..756c61e75 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts @@ -15,9 +15,21 @@ vi.mock("~/newstore/environments", async () => { return { __esModule: true, - aggregateEnvs$: new BehaviorSubject([ - { key: "EXISTING_ENV_VAR", value: "test_value" }, + aggregateEnvsWithSecrets$: new BehaviorSubject([ + { key: "EXISTING_ENV_VAR", value: "test_value", secret: false }, + { key: "EXISTING_ENV_VAR_2", value: "", secret: false }, ]), + getCurrentEnvironment: () => ({ + id: "1", + name: "some-env", + v: 1, + variables: { + key: "EXISTING_ENV_VAR", + value: "test_value", + secret: false, + }, + }), + getSelectedEnvironmentType: () => "MY_ENV", } }) @@ -51,7 +63,7 @@ describe("EnvironmentInspectorService", () => { expect(result.value).toContainEqual( expect.objectContaining({ - id: "environment", + id: "environment-not-found-0", isApplicable: true, text: { type: "text", @@ -91,7 +103,7 @@ describe("EnvironmentInspectorService", () => { expect(result.value).toContainEqual( expect.objectContaining({ - id: "environment", + id: "environment-not-found-0", isApplicable: true, text: { type: "text", @@ -134,7 +146,7 @@ describe("EnvironmentInspectorService", () => { expect(result.value).toContainEqual( expect.objectContaining({ - id: "environment", + id: "environment-not-found-0", isApplicable: true, text: { type: "text", @@ -161,5 +173,103 @@ describe("EnvironmentInspectorService", () => { expect(result.value).toHaveLength(0) }) + + it("should return an inspector result when the URL contains empty value in a environment variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the URL contains non empty value in a environemnt variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "<>", + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the headers contain empty value in a environment variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { key: "<>", value: "some-value", active: true }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the headers contain non empty value in a environemnt variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { key: "<>", value: "some-value", active: true }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) + + it("should return an inspector result when the params contain empty value in a environment variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { key: "<>", value: "some-value", active: true }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(1) + }) + + it("should not return an inspector result when the params contain non empty value in a environemnt variable", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = ref({ + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { key: "<>", value: "some-value", active: true }, + ], + }) + + const result = envInspector.getInspections(req) + + expect(result.value).toHaveLength(0) + }) }) }) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts index a3ebb66dd..c3a9c7912 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts @@ -9,7 +9,11 @@ import { Service } from "dioc" import { Ref, markRaw } from "vue" import IconPlusCircle from "~icons/lucide/plus-circle" import { HoppRESTRequest } from "@hoppscotch/data" -import { aggregateEnvs$ } from "~/newstore/environments" +import { + aggregateEnvsWithSecrets$, + getCurrentEnvironment, + getSelectedEnvironmentType, +} from "~/newstore/environments" import { invokeAction } from "~/helpers/actions" import { computed } from "vue" import { useStreamStatic } from "~/composables/stream" @@ -36,9 +40,13 @@ export class EnvironmentInspectorService extends Service implements Inspector { private readonly inspection = this.bind(InspectionService) - private aggregateEnvs = useStreamStatic(aggregateEnvs$, [], () => { - /* noop */ - })[0] + private aggregateEnvsWithSecrets = useStreamStatic( + aggregateEnvsWithSecrets$, + [], + () => { + /* noop */ + } + )[0] constructor() { super() @@ -49,9 +57,8 @@ export class EnvironmentInspectorService extends Service implements Inspector { /** * Validates the environment variables in the target array * @param target The target array to validate - * @param results The results array to push the results to * @param locations The location where results are to be displayed - * @returns The results array + * @returns The results array containing the results of the validation */ private validateEnvironmentVariables = ( target: any[], @@ -59,7 +66,7 @@ export class EnvironmentInspectorService extends Service implements Inspector { ) => { const newErrors: InspectorResult[] = [] - const envKeys = this.aggregateEnvs.value.map((e) => e.key) + const envKeys = this.aggregateEnvsWithSecrets.value.map((e) => e.key) target.forEach((element, index) => { if (isENVInString(element)) { @@ -68,29 +75,20 @@ export class EnvironmentInspectorService extends Service implements Inspector { if (extractedEnv) { extractedEnv.forEach((exEnv: string) => { const formattedExEnv = exEnv.slice(2, -2) - let itemLocation: InspectorLocation - if (locations.type === "header") { - itemLocation = { - type: "header", - position: locations.position, - index: index, - key: element, - } - } else if (locations.type === "parameter") { - itemLocation = { - type: "parameter", - position: locations.position, - index: index, - key: element, - } - } else { - itemLocation = { - type: "url", - } + const itemLocation: InspectorLocation = { + type: locations.type, + position: + locations.type === "url" || + locations.type === "body" || + locations.type === "response" + ? "key" + : locations.position, + index: index, + key: element, } if (!envKeys.includes(formattedExEnv)) { newErrors.push({ - id: "environment", + id: `environment-not-found-${newErrors.length}`, text: { type: "text", text: this.t("inspections.environment.not_found", { @@ -112,7 +110,7 @@ export class EnvironmentInspectorService extends Service implements Inspector { locations: itemLocation, doc: { text: this.t("action.learn_more"), - link: "https://docs.hoppscotch.io/", + link: "https://docs.hoppscotch.io/documentation/features/inspections", }, }) } @@ -124,6 +122,103 @@ export class EnvironmentInspectorService extends Service implements Inspector { return newErrors } + /** + * Checks if the environment variables in the target array are empty + * @param target The target array to validate + * @param locations The location where results are to be displayed + * @returns The results array containing the results of the validation + */ + private validateEmptyEnvironmentVariables = ( + target: any[], + locations: InspectorLocation + ) => { + const newErrors: InspectorResult[] = [] + + target.forEach((element, index) => { + if (isENVInString(element)) { + const extractedEnv = element.match(HOPP_ENVIRONMENT_REGEX) + + if (extractedEnv) { + extractedEnv.forEach((exEnv: string) => { + const formattedExEnv = exEnv.slice(2, -2) + + this.aggregateEnvsWithSecrets.value.forEach((env) => { + if (env.key === formattedExEnv) { + if (env.value === "") { + const itemLocation: InspectorLocation = { + type: locations.type, + position: + locations.type === "url" || + locations.type === "body" || + locations.type === "response" + ? "key" + : locations.position, + index: index, + key: element, + } + + const currentSelectedEnvironment = getCurrentEnvironment() + const currentEnvironmentType = getSelectedEnvironmentType() + + let invokeActionType: + | "modals.my.environment.edit" + | "modals.team.environment.edit" + | "modals.global.environment.update" = + "modals.my.environment.edit" + + if (env.sourceEnv === "Global") { + invokeActionType = "modals.global.environment.update" + } else if (currentEnvironmentType === "MY_ENV") { + invokeActionType = "modals.my.environment.edit" + } else if (currentEnvironmentType === "TEAM_ENV") { + invokeActionType = "modals.team.environment.edit" + } else { + invokeActionType = "modals.my.environment.edit" + } + + newErrors.push({ + id: `environment-empty-${newErrors.length}`, + text: { + type: "text", + text: this.t("inspections.environment.empty_value", { + variable: exEnv, + }), + }, + icon: markRaw(IconPlusCircle), + action: { + text: this.t( + "inspections.environment.add_environment_value" + ), + apply: () => { + invokeAction(invokeActionType, { + envName: + env.sourceEnv !== "Global" + ? currentSelectedEnvironment.name + : "Global", + variableName: formattedExEnv, + isSecret: env.secret, + }) + }, + }, + severity: 2, + isApplicable: true, + locations: itemLocation, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/documentation/features/inspections", + }, + }) + } + } + }) + }) + } + } + }) + + return newErrors + } + getInspections(req: Readonly>) { return computed(() => { const results: InspectorResult[] = [] @@ -132,12 +227,25 @@ export class EnvironmentInspectorService extends Service implements Inspector { const params = req.value.params + /** + * Validate the environment variables in the URL + */ + const url = req.value.endpoint + results.push( - ...this.validateEnvironmentVariables([req.value.endpoint], { + ...this.validateEnvironmentVariables([url], { + type: "url", + }) + ) + results.push( + ...this.validateEmptyEnvironmentVariables([url], { type: "url", }) ) + /** + * Validate the environment variables in the headers + */ const headerKeys = Object.values(headers).map((header) => header.key) results.push( @@ -146,6 +254,12 @@ export class EnvironmentInspectorService extends Service implements Inspector { position: "key", }) ) + results.push( + ...this.validateEmptyEnvironmentVariables(headerKeys, { + type: "header", + position: "key", + }) + ) const headerValues = Object.values(headers).map((header) => header.value) @@ -155,7 +269,16 @@ export class EnvironmentInspectorService extends Service implements Inspector { position: "value", }) ) + results.push( + ...this.validateEmptyEnvironmentVariables(headerValues, { + type: "header", + position: "value", + }) + ) + /** + * Validate the environment variables in the parameters + */ const paramsKeys = Object.values(params).map((param) => param.key) results.push( @@ -164,6 +287,12 @@ export class EnvironmentInspectorService extends Service implements Inspector { position: "key", }) ) + results.push( + ...this.validateEmptyEnvironmentVariables(paramsKeys, { + type: "parameter", + position: "key", + }) + ) const paramsValues = Object.values(params).map((param) => param.value) @@ -174,6 +303,13 @@ export class EnvironmentInspectorService extends Service implements Inspector { }) ) + results.push( + ...this.validateEmptyEnvironmentVariables(paramsValues, { + type: "parameter", + position: "value", + }) + ) + return results }) } diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts index 3f8b893f3..e5124d068 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts @@ -67,7 +67,7 @@ export class HeaderInspectorService extends Service implements Inspector { }, doc: { text: this.t("action.learn_more"), - link: "https://docs.hoppscotch.io/", + link: "https://docs.hoppscotch.io/documentation/features/inspections", }, }) } diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts index 014294511..fc23cdf25 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts @@ -67,7 +67,7 @@ export class ResponseInspectorService extends Service implements Inspector { }, doc: { text: this.t("action.learn_more"), - link: "https://docs.hoppscotch.io/", + link: "https://docs.hoppscotch.io/documentation/features/inspections", }, }) } diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index eb04fbd28..02385fe9f 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -4,6 +4,7 @@ import { HoppGQLDocument } from "~/helpers/graphql/document" import { HoppRESTDocument } from "~/helpers/rest/document" import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history" import { SettingsDef, getDefaultSettings } from "~/newstore/settings" +import { SecretVariable } from "~/services/secret-environment.service" import { PersistableTabState } from "~/services/tab" type VUEX_DATA = { @@ -19,7 +20,7 @@ const DEFAULT_SETTINGS = getDefaultSettings() export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 1, + v: 2, name: "Echo", folders: [], requests: [ @@ -36,12 +37,14 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ body: { contentType: null, body: null }, }, ], + auth: { authType: "none", authActive: true }, + headers: [], }, ] export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 1, + v: 2, name: "Echo", folders: [], requests: [ @@ -55,20 +58,30 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ auth: { authType: "none", authActive: true }, }, ], + auth: { authType: "none", authActive: true }, + headers: [], }, ] export const ENVIRONMENTS_MOCK: Environment[] = [ { + v: 1, + id: "ENV_1", name: "globals", variables: [ { key: "test-global-key", value: "test-global-value", + secret: false, }, ], }, - { name: "Test", variables: [{ key: "test-key", value: "test-value" }] }, + { + v: 1, + id: "ENV_2", + name: "Test", + variables: [{ key: "test-key", value: "test-value", secret: false }], + }, ] export const SELECTED_ENV_INDEX_MOCK = { @@ -98,7 +111,7 @@ export const MQTT_REQUEST_MOCK = { } export const GLOBAL_ENV_MOCK: Environment["variables"] = [ - { key: "test-key", value: "test-value" }, + { key: "test-key", value: "test-value", secret: false }, ] export const VUEX_DATA_MOCK: VUEX_DATA = { @@ -201,3 +214,11 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { }, ], } + +export const SECRET_ENVIRONMENTS_MOCK: Record = { + clryz7ir7002al4162bsj0azg: { + key: "test-key", + value: "test-value", + varIndex: 1, + }, +} diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts index 8d6e12555..8fdf0c7e1 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/index.spec.ts @@ -56,12 +56,14 @@ import { REST_COLLECTIONS_MOCK, REST_HISTORY_MOCK, REST_TAB_STATE_MOCK, + SECRET_ENVIRONMENTS_MOCK, SELECTED_ENV_INDEX_MOCK, SOCKET_IO_REQUEST_MOCK, SSE_REQUEST_MOCK, VUEX_DATA_MOCK, WEBSOCKET_REQUEST_MOCK, } from "./__mocks__" +import { SecretEnvironmentService } from "~/services/secret-environment.service" vi.mock("~/modules/i18n", () => { return { @@ -122,10 +124,12 @@ const spyOnSetItem = () => vi.spyOn(Storage.prototype, "setItem") const bindPersistenceService = ({ mockGQLTabService = false, mockRESTTabService = false, + mockSecretEnvironmentsService = false, mock = {}, }: { mockGQLTabService?: boolean mockRESTTabService?: boolean + mockSecretEnvironmentsService?: boolean mock?: Record } = {}) => { const container = new TestContainer() @@ -138,6 +142,10 @@ const bindPersistenceService = ({ container.bindMock(RESTTabService, mock) } + if (mockSecretEnvironmentsService) { + container.bindMock(SecretEnvironmentService, mock) + } + container.bind(PersistenceService) const service = container.bind(PersistenceService) @@ -893,7 +901,12 @@ describe("PersistenceService", () => { // Invalid shape for `environments` const environments = [ // `entries` -> `variables` - { name: "Test", entries: [{ key: "test-key", value: "test-value" }] }, + { + v: 1, + id: "ENV_1", + name: "Test", + entries: [{ key: "test-key", value: "test-value", secret: false }], + }, ] window.localStorage.setItem( @@ -1044,6 +1057,119 @@ describe("PersistenceService", () => { }) }) + describe("Setup secret Environments persistence", () => { + // Key read from localStorage across test cases + const secretEnvironmentsKey = "secretEnvironments" + + const loadSecretEnvironmentsFromPersistedStateFn = vi.fn() + const mock = { + loadSecretEnvironmentsFromPersistedState: + loadSecretEnvironmentsFromPersistedStateFn, + } + + it(`shows an error and sets the entry as a backup in localStorage if "${secretEnvironmentsKey}" read from localStorage doesn't match the schema`, () => { + // Invalid shape for `secretEnvironments` + const secretEnvironments = { + clryz7ir7002al4162bsj0azg: { + key: "ENV_KEY", + value: "ENV_VALUE", + }, + } + + window.localStorage.setItem( + secretEnvironmentsKey, + JSON.stringify(secretEnvironments) + ) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).toHaveBeenCalledWith( + expect.stringContaining(secretEnvironmentsKey) + ) + expect(setItemSpy).toHaveBeenCalledWith( + `${secretEnvironmentsKey}-backup`, + JSON.stringify(secretEnvironments) + ) + }) + + it("loads secret environments from the state persisted in localStorage and sets watcher for `persistableSecretEnvironment`", () => { + const secretEnvironment = SECRET_ENVIRONMENTS_MOCK + window.localStorage.setItem( + secretEnvironmentsKey, + JSON.stringify(secretEnvironment) + ) + + const getItemSpy = spyOnGetItem() + + invokeSetupLocalPersistence({ + mockSecretEnvironmentsService: true, + mock, + }) + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(loadSecretEnvironmentsFromPersistedStateFn).toHaveBeenCalledWith( + secretEnvironment + ) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500 } + ) + }) + + it(`skips schema parsing and the loading of persisted secret environments if there is no "${secretEnvironmentsKey}" key present in localStorage`, () => { + window.localStorage.removeItem(secretEnvironmentsKey) + + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + invokeSetupLocalPersistence({ + mockSecretEnvironmentsService: true, + mock, + }) + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + expect(setItemSpy).not.toHaveBeenCalled() + + expect(watchDebounced).toHaveBeenCalled() + }) + + it("logs an error to the console on failing to parse persisted secret environments", () => { + window.localStorage.setItem(secretEnvironmentsKey, "invalid-json") + + console.error = vi.fn() + const getItemSpy = spyOnGetItem() + const setItemSpy = spyOnSetItem() + + invokeSetupLocalPersistence() + + expect(getItemSpy).toHaveBeenCalledWith(secretEnvironmentsKey) + + expect(toastErrorFn).not.toHaveBeenCalledWith(secretEnvironmentsKey) + expect(setItemSpy).not.toHaveBeenCalled() + + expect(console.error).toHaveBeenCalledWith( + `Failed parsing persisted secret environment, state:`, + window.localStorage.getItem(secretEnvironmentsKey) + ) + expect(watchDebounced).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + { debounce: 500 } + ) + }) + }) + describe("setup WebSocket persistence", () => { // Key read from localStorage across test cases const wsRequestKey = "WebsocketRequest" diff --git a/packages/hoppscotch-common/src/services/persistence/index.ts b/packages/hoppscotch-common/src/services/persistence/index.ts index 04a153853..84d318476 100644 --- a/packages/hoppscotch-common/src/services/persistence/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/index.ts @@ -67,10 +67,12 @@ import { SETTINGS_SCHEMA, SOCKET_IO_REQUEST_SCHEMA, SSE_REQUEST_SCHEMA, + SECRET_ENVIRONMENT_VARIABLE_SCHEMA, THEME_COLOR_SCHEMA, VUEX_SCHEMA, WEBSOCKET_REQUEST_SCHEMA, } from "./validation-schemas" +import { SecretEnvironmentService } from "../secret-environment.service" /** * This service compiles persistence logic across the codebase @@ -81,6 +83,10 @@ export class PersistenceService extends Service { private readonly restTabService = this.bind(RESTTabService) private readonly gqlTabService = this.bind(GQLTabService) + private readonly secretEnvironmentService = this.bind( + SecretEnvironmentService + ) + public hoppLocalConfigStorage: StorageLike = localStorage constructor() { @@ -438,6 +444,56 @@ export class PersistenceService extends Service { }) } + private setupSecretEnvironmentsPersistence() { + const secretEnvironmentsKey = "secretEnvironments" + const secretEnvironmentsData = window.localStorage.getItem( + secretEnvironmentsKey + ) + + try { + if (secretEnvironmentsData) { + let parsedSecretEnvironmentsData = JSON.parse(secretEnvironmentsData) + + // Validate data read from localStorage + const result = SECRET_ENVIRONMENT_VARIABLE_SCHEMA.safeParse( + parsedSecretEnvironmentsData + ) + + if (result.success) { + parsedSecretEnvironmentsData = result.data + } else { + this.showErrorToast(secretEnvironmentsKey) + window.localStorage.setItem( + `${secretEnvironmentsKey}-backup`, + JSON.stringify(parsedSecretEnvironmentsData) + ) + } + + this.secretEnvironmentService.loadSecretEnvironmentsFromPersistedState( + parsedSecretEnvironmentsData + ) + } + } catch (e) { + console.error( + `Failed parsing persisted secret environment, state:`, + secretEnvironmentsData + ) + } + + watchDebounced( + this.secretEnvironmentService.persistableSecretEnvironments, + (newSecretEnvironment) => { + window.localStorage.setItem( + secretEnvironmentsKey, + JSON.stringify(newSecretEnvironment) + ) + }, + { + debounce: 500, + } + ) + } + private setupSelectedEnvPersistence() { const selectedEnvIndexKey = "selectedEnvIndex" let selectedEnvIndexValue = JSON.parse( @@ -697,6 +753,8 @@ export class PersistenceService extends Service { this.setupSocketIOPersistence() this.setupSSEPersistence() this.setupMQTTPersistence() + + this.setupSecretEnvironmentsPersistence() } /** diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index 72ca18415..f91194e15 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -160,7 +160,6 @@ export const SELECTED_ENV_INDEX_SCHEMA = z.nullable( type: z.literal("TEAM_ENV"), teamID: z.string(), teamEnvID: z.string(), - // ! Versioned entity environment: entityReference(Environment), }), ]) @@ -212,13 +211,19 @@ export const MQTT_REQUEST_SCHEMA = z.nullable( export const GLOBAL_ENV_SCHEMA = z.union([ z.array(z.never()), + z.array( - z - .object({ + z.union([ + z.object({ + key: z.string(), + secret: z.literal(true), + }), + z.object({ key: z.string(), value: z.string(), - }) - .strict() + secret: z.literal(false), + }), + ]) ), ]) @@ -339,12 +344,34 @@ const HoppTestDataSchema = z.lazy(() => .strict() ) -const EnvironmentVariablesSchema = z - .object({ +const EnvironmentVariablesSchema = z.union([ + z.object({ key: z.string(), value: z.string(), - }) - .strict() + secret: z.literal(false), + }), + z.object({ + key: z.string(), + secret: z.literal(true), + }), +]) + +export const SECRET_ENVIRONMENT_VARIABLE_SCHEMA = z.union([ + z.object({}).strict(), + + z.record( + z.string(), + z.array( + z + .object({ + key: z.string(), + value: z.string(), + varIndex: z.number(), + }) + .strict() + ) + ), +]) const HoppTestResultSchema = z .object({ @@ -358,7 +385,11 @@ const HoppTestResultSchema = z .object({ additions: z.array(EnvironmentVariablesSchema), updations: z.array( - EnvironmentVariablesSchema.extend({ previousValue: z.string() }) + EnvironmentVariablesSchema.refine((x) => !x.secret).and( + z.object({ + previousValue: z.string(), + }) + ) ), deletions: z.array(EnvironmentVariablesSchema), }) @@ -367,7 +398,11 @@ const HoppTestResultSchema = z .object({ additions: z.array(EnvironmentVariablesSchema), updations: z.array( - EnvironmentVariablesSchema.extend({ previousValue: z.string() }) + EnvironmentVariablesSchema.refine((x) => !x.secret).and( + z.object({ + previousValue: z.string(), + }) + ) ), deletions: z.array(EnvironmentVariablesSchema), }) diff --git a/packages/hoppscotch-common/src/services/secret-environment.service.ts b/packages/hoppscotch-common/src/services/secret-environment.service.ts new file mode 100644 index 000000000..ff6ff7641 --- /dev/null +++ b/packages/hoppscotch-common/src/services/secret-environment.service.ts @@ -0,0 +1,130 @@ +import { Service } from "dioc" +import { reactive } from "vue" +import { computed } from "vue" + +/** + * Defines a secret environment variable. + */ +export type SecretVariable = { + key: string + value: string + varIndex: number +} + +/** + * This service is used to store and manage secret environments. + * The secret environments are not synced with the server. + * hence they are not persisted in the database. They are stored + * in the local storage of the browser. + */ +export class SecretEnvironmentService extends Service { + public static readonly ID = "SECRET_ENVIRONMENT_SERVICE" + + /** + * Map of secret environments. + * The key is the ID of the secret environment. + * The value is the list of secret variables. + */ + public secretEnvironments = reactive(new Map()) + + constructor() { + super() + } + + /** + * Add a new secret environment. + * @param id ID of the environment + * @param secretVars List of secret variables + */ + public addSecretEnvironment(id: string, secretVars: SecretVariable[]) { + this.secretEnvironments.set(id, secretVars) + } + + /** + * Get a secret environment. + * @param id ID of the environment + */ + public getSecretEnvironment(id: string) { + return this.secretEnvironments.get(id) + } + + /** + * Get a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public getSecretEnvironmentVariable(id: string, varIndex: number) { + const secretVars = this.getSecretEnvironment(id) + return secretVars?.find((secretVar) => secretVar.varIndex === varIndex) + } + + /** + * Used to get the value of a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment += */ + public getSecretEnvironmentVariableValue(id: string, varIndex: number) { + const secretVar = this.getSecretEnvironmentVariable(id, varIndex) + return secretVar?.value + } + + /** + * + * @param secretEnvironments Used to load secret environments from persisted state. + */ + public loadSecretEnvironmentsFromPersistedState( + secretEnvironments: Record + ) { + if (secretEnvironments) { + this.secretEnvironments.clear() + + Object.entries(secretEnvironments).forEach(([id, secretVars]) => { + this.addSecretEnvironment(id, secretVars) + }) + } + } + + /** + * Delete a secret environment. + * @param id ID of the environment + */ + public deleteSecretEnvironment(id: string) { + this.secretEnvironments.delete(id) + } + + /** + * Delete a secret environment variable. + * @param id ID of the environment + * @param varIndex Index of the variable in the environment + */ + public removeSecretEnvironmentVariable(id: string, varIndex: number) { + const secretVars = this.getSecretEnvironment(id) + const newSecretVars = secretVars?.filter( + (secretVar) => secretVar.varIndex !== varIndex + ) + this.secretEnvironments.set(id, newSecretVars || []) + } + + /** + * Used to update thye ID of a secret environment. + * Used while syncing with the server. + * @param oldID old ID of the environment + * @param newID new ID of the environment + */ + public updateSecretEnvironmentID(oldID: string, newID: string) { + const secretVars = this.getSecretEnvironment(oldID) + this.secretEnvironments.set(newID, secretVars || []) + this.secretEnvironments.delete(oldID) + } + + /** + * Used to update the value of a secret environment variable. + */ + public persistableSecretEnvironments = computed(() => { + const secretEnvironments: Record = {} + this.secretEnvironments.forEach((secretVars, id) => { + secretEnvironments[id] = secretVars + }) + return secretEnvironments + }) +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts index 1a2781e14..1eb6a63c1 100644 --- a/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/environment.searcher.ts @@ -248,9 +248,7 @@ export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearche this.duplicateSelectedEnv() break case "edit_global_env": - invokeAction(`modals.my.environment.edit`, { - envName: "Global", - }) + invokeAction(`modals.global.environment.update`, {}) break case "duplicate_global_env": this.duplicateGlobalEnv() diff --git a/packages/hoppscotch-data/src/environment/index.ts b/packages/hoppscotch-data/src/environment/index.ts index 29c0b8ecc..94a4f04e1 100644 --- a/packages/hoppscotch-data/src/environment/index.ts +++ b/packages/hoppscotch-data/src/environment/index.ts @@ -2,22 +2,38 @@ import * as E from "fp-ts/Either" import { pipe } from "fp-ts/function" import { InferredEntity, createVersionedEntity } from "verzod" +import { z } from "zod" + import V0_VERSION from "./v/0" +import V1_VERSION from "./v/1" + +const versionedObject = z.object({ + v: z.number(), +}) export const Environment = createVersionedEntity({ - latestVersion: 0, + latestVersion: 1, versionMap: { - 0: V0_VERSION + 0: V0_VERSION, + 1: V1_VERSION, + }, + getVersion(data) { + const versionCheck = versionedObject.safeParse(data) + + if (versionCheck.success) return versionCheck.data.v + + // For V0 we have to check the schema + const result = V0_VERSION.schema.safeParse(data) + return result.success ? 0 : null }, - getVersion(x) { - return V0_VERSION.schema.safeParse(x).success - ? 0 - : null - } }) export type Environment = InferredEntity +export type EnvironmentVariable = InferredEntity< + typeof Environment +>["variables"][number] + const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<>" /** @@ -31,6 +47,8 @@ const ENV_MAX_EXPAND_LIMIT = 10 */ const ENV_EXPAND_LOOP = "ENV_EXPAND_LOOP" as const +export const EnvironmentSchemaVersion = 1 + export function parseBodyEnvVariablesE( body: string, env: Environment["variables"] @@ -43,7 +61,11 @@ export function parseBodyEnvVariablesE( const found = env.find( (envVar) => envVar.key === key.replace(/[<>]/g, "") ) - return found ? found.value : key + + if (found && "value" in found) { + return found.value + } + return key }) depth++ @@ -68,7 +90,10 @@ export const parseBodyEnvVariables = ( export function parseTemplateStringE( str: string, - variables: Environment["variables"] + variables: + | Environment["variables"] + | { secret: true; value: string; key: string }[], + maskValue = false ) { if (!variables || !str) { return E.right(str) @@ -78,10 +103,21 @@ export function parseTemplateStringE( let depth = 0 while (result.match(REGEX_ENV_VAR) != null && depth <= ENV_MAX_EXPAND_LIMIT) { - result = decodeURI(encodeURI(result)).replace( - REGEX_ENV_VAR, - (_, p1) => variables.find((x) => x.key === p1)?.value || "" - ) + result = decodeURI(encodeURI(result)).replace(REGEX_ENV_VAR, (_, p1) => { + const variable = variables.find((x) => x && x.key === p1) + if (variable && "value" in variable) { + // Mask the value if it is a secret and explicitly specified + if (variable.secret && maskValue) { + return "*".repeat( + (variable as { secret: true; value: string; key: string }).value + .length + ) + } + return variable.value + } + + return "" + }) depth++ } @@ -90,14 +126,52 @@ export function parseTemplateStringE( : E.right(result) } +export type NonSecretEnvironmentVariable = Extract< + EnvironmentVariable, + { secret: false } +> + +export type NonSecretEnvironment = Omit & { + variables: NonSecretEnvironmentVariable[] +} + /** * @deprecated Use `parseTemplateStringE` instead */ export const parseTemplateString = ( str: string, - variables: Environment["variables"] + variables: + | Environment["variables"] + | { secret: true; value: string; key: string }[], + maskValue = false ) => pipe( - parseTemplateStringE(str, variables), + parseTemplateStringE(str, variables, maskValue), E.getOrElse(() => str) ) + +export const translateToNewEnvironmentVariables = ( + x: any +): Environment["variables"][number] => { + return { + key: x.key, + value: x.value, + secret: false, + } +} + +export const translateToNewEnvironment = (x: any): Environment => { + if (x.v && x.v === EnvironmentSchemaVersion) return x + + // Legacy + const id = x.id ?? "" + const name = x.name ?? "Untitled" + const variables = (x.variables ?? []).map(translateToNewEnvironmentVariables) + + return { + v: EnvironmentSchemaVersion, + id, + name, + variables, + } +} diff --git a/packages/hoppscotch-data/src/environment/v/0.ts b/packages/hoppscotch-data/src/environment/v/0.ts index 8e030bf75..74b189893 100644 --- a/packages/hoppscotch-data/src/environment/v/0.ts +++ b/packages/hoppscotch-data/src/environment/v/0.ts @@ -9,10 +9,10 @@ export const V0_SCHEMA = z.object({ key: z.string(), value: z.string(), }) - ) + ), }) export default defineVersion({ initial: true, - schema: V0_SCHEMA + schema: V0_SCHEMA, }) diff --git a/packages/hoppscotch-data/src/environment/v/1.ts b/packages/hoppscotch-data/src/environment/v/1.ts new file mode 100644 index 000000000..6ab68d79f --- /dev/null +++ b/packages/hoppscotch-data/src/environment/v/1.ts @@ -0,0 +1,42 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V0_SCHEMA } from "./0" + +export const V1_SCHEMA = z.object({ + v: z.literal(1), + id: z.string(), + name: z.string(), + variables: z.array( + z.union([ + z.object({ + key: z.string(), + secret: z.literal(true), + }), + z.object({ + key: z.string(), + value: z.string(), + secret: z.literal(false), + }), + ]) + ), +}) + +export default defineVersion({ + initial: false, + schema: V1_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 1, + id: old.id ?? "", + variables: old.variables.map((variable) => { + return { + ...variable, + secret: false, + } + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/preRequest.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/preRequest.spec.ts index cf7ce824a..ce8bf9555 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/preRequest.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/preRequest.spec.ts @@ -12,16 +12,16 @@ describe("runPreRequestScript", () => { { global: [], selected: [ - { key: "bob", value: "oldbob" }, - { key: "foo", value: "bar" }, + { key: "bob", value: "oldbob", secret: false }, + { key: "foo", value: "bar", secret: false }, ], } )() ).resolves.toEqualRight({ global: [], selected: [ - { key: "bob", value: "newbob" }, - { key: "foo", value: "bar" }, + { key: "bob", value: "newbob", secret: false }, + { key: "foo", value: "bar", secret: false }, ], }) }) @@ -35,8 +35,8 @@ describe("runPreRequestScript", () => { { global: [], selected: [ - { key: "bob", value: "oldbob" }, - { key: "foo", value: "bar" }, + { key: "bob", value: "oldbob", secret: false }, + { key: "foo", value: "bar", secret: false }, ], } )() @@ -52,8 +52,8 @@ describe("runPreRequestScript", () => { { global: [], selected: [ - { key: "bob", value: "oldbob" }, - { key: "foo", value: "bar" }, + { key: "bob", value: "oldbob", secret: false }, + { key: "foo", value: "bar", secret: false }, ], } )() @@ -69,8 +69,8 @@ describe("runPreRequestScript", () => { { global: [], selected: [ - { key: "bob", value: "oldbob" }, - { key: "foo", value: "bar" }, + { key: "bob", value: "oldbob", secret: false }, + { key: "foo", value: "bar", secret: false }, ], } )() @@ -87,7 +87,7 @@ describe("runPreRequestScript", () => { )() ).resolves.toEqualRight({ global: [], - selected: [{ key: "foo", value: "bar" }], + selected: [{ key: "foo", value: "bar", secret: false }], }) }) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/base64-helper-functions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/base64-helper-functions.spec.ts index 8e54af0d9..c1fc1e106 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/base64-helper-functions.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/base64-helper-functions.spec.ts @@ -10,13 +10,13 @@ describe("Base64 helper functions", () => { atob: { script: `pw.env.set("atob", atob("SGVsbG8gV29ybGQ="))`, environment: { - selected: [{ key: "atob", value: "Hello World" }], + selected: [{ key: "atob", value: "Hello World", secret: false }], }, }, btoa: { script: `pw.env.set("btoa", btoa("Hello World"))`, environment: { - selected: [{ key: "btoa", value: "SGVsbG8gV29ybGQ=" }], + selected: [{ key: "btoa", value: "SGVsbG8gV29ybGQ=", secret: false }], }, }, } diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/get.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/get.spec.ts index 7ca8b0dad..fb433eac8 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/get.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/get.spec.ts @@ -31,6 +31,7 @@ describe("pw.env.get", () => { { key: "a", value: "b", + secret: false, }, ], } @@ -59,6 +60,7 @@ describe("pw.env.get", () => { { key: "a", value: "b", + secret: false, }, ], selected: [], @@ -112,12 +114,14 @@ describe("pw.env.get", () => { { key: "a", value: "global val", + secret: false, }, ], selected: [ { key: "a", value: "selected val", + secret: false, }, ], } @@ -147,6 +151,7 @@ describe("pw.env.get", () => { { key: "a", value: "<>", + secret: false, }, ], } diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/getResolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/getResolve.spec.ts index 740f5d104..0d64e6d41 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/getResolve.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/getResolve.spec.ts @@ -31,6 +31,7 @@ describe("pw.env.getResolve", () => { { key: "a", value: "b", + secret: false, }, ], } @@ -59,6 +60,7 @@ describe("pw.env.getResolve", () => { { key: "a", value: "b", + secret: false, }, ], selected: [], @@ -112,12 +114,14 @@ describe("pw.env.getResolve", () => { { key: "a", value: "global val", + secret: false, }, ], selected: [ { key: "a", value: "selected val", + secret: false, }, ], } @@ -147,10 +151,12 @@ describe("pw.env.getResolve", () => { { key: "a", value: "<>", + secret: false, }, { key: "hello", value: "there", + secret: false, }, ], } @@ -180,10 +186,12 @@ describe("pw.env.getResolve", () => { { key: "a", value: "<>", + secret: false, }, { key: "hello", value: "<>", + secret: false, }, ], } diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/resolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/resolve.spec.ts index 044fcff83..022d7083a 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/resolve.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/resolve.spec.ts @@ -43,6 +43,7 @@ describe("pw.env.resolve", () => { { key: "hello", value: "there", + secret: false, }, ], selected: [], @@ -73,6 +74,7 @@ describe("pw.env.resolve", () => { { key: "hello", value: "there", + secret: false, }, ], } @@ -101,12 +103,14 @@ describe("pw.env.resolve", () => { { key: "hello", value: "yo", + secret: false, }, ], selected: [ { key: "hello", value: "there", + secret: false, }, ], } @@ -136,10 +140,12 @@ describe("pw.env.resolve", () => { { key: "hello", value: "<>", + secret: false, }, { key: "there", value: "<>", + secret: false, }, ], } diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/set.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/set.spec.ts index b81b39218..d3726d249 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/set.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/set.spec.ts @@ -35,6 +35,7 @@ describe("pw.env.set", () => { { key: "a", value: "b", + secret: false, }, ], } @@ -45,6 +46,7 @@ describe("pw.env.set", () => { { key: "a", value: "c", + secret: false, }, ], }) @@ -62,6 +64,7 @@ describe("pw.env.set", () => { { key: "a", value: "b", + secret: false, }, ], selected: [], @@ -73,6 +76,7 @@ describe("pw.env.set", () => { { key: "a", value: "c", + secret: false, }, ], }) @@ -90,12 +94,14 @@ describe("pw.env.set", () => { { key: "a", value: "b", + secret: false, }, ], selected: [ { key: "a", value: "d", + secret: false, }, ], } @@ -106,12 +112,14 @@ describe("pw.env.set", () => { { key: "a", value: "b", + secret: false, }, ], selected: [ { key: "a", value: "c", + secret: false, }, ], }) @@ -136,6 +144,7 @@ describe("pw.env.set", () => { { key: "a", value: "c", + secret: false, }, ], }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/unset.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/unset.spec.ts index aeda66d71..eb6ae5783 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/unset.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/testing/envs/unset.spec.ts @@ -35,6 +35,7 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], } @@ -57,6 +58,7 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], selected: [], @@ -80,12 +82,14 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://httpbin.org", + secret: false, }, ], selected: [ { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], } @@ -96,6 +100,7 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://httpbin.org", + secret: false, }, ], selected: [], @@ -114,16 +119,19 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], selected: [ { key: "baseUrl", value: "https://httpbin.org", + secret: false, }, { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], } @@ -134,12 +142,14 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], selected: [ { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], }) @@ -157,10 +167,12 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://httpbin.org/", + secret: false, }, { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], selected: [], @@ -172,6 +184,7 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], selected: [], @@ -225,6 +238,7 @@ describe("pw.env.unset", () => { { key: "baseUrl", value: "https://echo.hoppscotch.io", + secret: false, }, ], } diff --git a/packages/hoppscotch-js-sandbox/src/types/index.ts b/packages/hoppscotch-js-sandbox/src/types/index.ts index 1b426d9a5..71d89d542 100644 --- a/packages/hoppscotch-js-sandbox/src/types/index.ts +++ b/packages/hoppscotch-js-sandbox/src/types/index.ts @@ -1,5 +1,3 @@ -import { Environment } from "@hoppscotch/data" - /** * The response object structure exposed to the test script */ @@ -43,6 +41,13 @@ export type TestDescriptor = { children: TestDescriptor[] } +// Representation of a transformed state for environment variables in the sandbox +type TransformedEnvironmentVariable = { + key: string + value: string + secret: boolean +} + /** * Defines the result of a test script execution */ @@ -50,8 +55,8 @@ export type TestDescriptor = { export type TestResult = { tests: TestDescriptor[] envs: { - global: Environment["variables"] - selected: Environment["variables"] + global: TransformedEnvironmentVariable[] + selected: TransformedEnvironmentVariable[] } } diff --git a/packages/hoppscotch-js-sandbox/src/utils.ts b/packages/hoppscotch-js-sandbox/src/utils.ts index f7920ff88..a6d298c23 100644 --- a/packages/hoppscotch-js-sandbox/src/utils.ts +++ b/packages/hoppscotch-js-sandbox/src/utils.ts @@ -38,13 +38,20 @@ const setEnv = ( const indexInGlobal = findEnvIndex(envName, global) if (indexInSelected >= 0) { - selected[indexInSelected].value = envValue + const selectedEnv = selected[indexInSelected] + if ("value" in selectedEnv) { + selectedEnv.value = envValue + } } else if (indexInGlobal >= 0) { - global[indexInGlobal].value = envValue + if ("value" in global[indexInGlobal]) { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(global[indexInGlobal] as { value: string }).value = envValue + } } else { selected.push({ key: envName, value: envValue, + secret: false, }) } @@ -86,9 +93,9 @@ const getSharedMethods = (envs: TestResult["envs"]) => { const result = pipe( getEnv(key, updatedEnvs), - O.match( + O.fold( () => undefined, - ({ value }) => String(value) + (env) => String(env.value) ) ) @@ -104,14 +111,13 @@ const getSharedMethods = (envs: TestResult["envs"]) => { getEnv(key, updatedEnvs), E.fromOption(() => "INVALID_KEY" as const), - E.map(({ value }) => + E.map((e) => pipe( - parseTemplateStringE(value, [ + parseTemplateStringE(e.value, [ ...updatedEnvs.selected, ...updatedEnvs.global, - ]), - // If the recursive resolution failed, return the unresolved value - E.getOrElse(() => value) + ]), // If the recursive resolution failed, return the unresolved value + E.getOrElse(() => e.value) ) ), E.map((x) => String(x)), diff --git a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts index 93b88cac5..a348f10ed 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.platform.ts @@ -81,6 +81,7 @@ async function loadUserEnvironments() { runDispatchWithOutSyncing(() => { replaceEnvironments( environments.map(({ id, variables, name }) => ({ + v: 1, id, name, variables: JSON.parse(variables), @@ -164,6 +165,7 @@ function setupUserEnvironmentUpdatedSubscription() { if ((localIndex || localIndex == 0) && name) { runDispatchWithOutSyncing(() => { updateEnvironment(localIndex, { + v: 1, id, name, variables: JSON.parse(variables), diff --git a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts index fb4094204..193aa3282 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/environments/environments.sync.ts @@ -20,10 +20,14 @@ import { deleteUserEnvironment, updateUserEnvironment, } from "./environments.api" +import { SecretEnvironmentService } from "@hoppscotch/common/services/secret-environment.service" +import { getService } from "@hoppscotch/common/modules/dioc" export const environmentsMapper = createMapper() export const globalEnvironmentMapper = createMapper() +const secretEnvironmentService = getService(SecretEnvironmentService) + export const storeSyncDefinition: StoreSyncDefinitionOf< typeof environmentsStore > = { @@ -34,6 +38,12 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< if (E.isRight(res)) { const id = res.right.createUserEnvironment.id + + secretEnvironmentService.updateSecretEnvironmentID( + environmentsStore.value.environments[lastCreatedEnvIndex].id, + id + ) + environmentsStore.value.environments[lastCreatedEnvIndex].id = id removeDuplicateEntry(id) } @@ -84,7 +94,6 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< }, updateEnvironment({ envIndex, updatedEnv }) { const backendId = environmentsStore.value.environments[envIndex].id - if (backendId) { updateUserEnvironment(backendId, updatedEnv)() } @@ -97,7 +106,12 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< setGlobalVariables({ entries }) { const backendId = getGlobalVariableID() if (backendId) { - updateUserEnvironment(backendId, { name: "", variables: entries })() + updateUserEnvironment(backendId, { + name: "", + variables: entries, + id: "", + v: 1, + })() } }, clearGlobalVariables() {