feat: secret variables in environments (#3779)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Nivedin
2024-02-08 21:58:42 +05:30
committed by GitHub
parent 16803acb26
commit 00862eb192
55 changed files with 2141 additions and 439 deletions

View File

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

View File

@@ -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: "<<EXISTING_ENV_VAR_2>>",
})
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: "<<EXISTING_ENV_VAR>>",
})
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: "<<EXISTING_ENV_VAR_2>>", 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: "<<EXISTING_ENV_VAR>>", 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: "<<EXISTING_ENV_VAR_2>>", 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: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
],
})
const result = envInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
})
})

View File

@@ -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<Ref<HoppRESTRequest>>) {
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
})
}

View File

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

View File

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

View File

@@ -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<HoppRESTDocument> = {
},
],
}
export const SECRET_ENVIRONMENTS_MOCK: Record<string, SecretVariable> = {
clryz7ir7002al4162bsj0azg: {
key: "test-key",
value: "test-value",
varIndex: 1,
},
}

View File

@@ -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<string, unknown>
} = {}) => {
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"

View File

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

View File

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

View File

@@ -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<string, SecretVariable[]>())
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<string, SecretVariable[]>
) {
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<string, SecretVariable[]> = {}
this.secretEnvironments.forEach((secretVars, id) => {
secretEnvironments[id] = secretVars
})
return secretEnvironments
})
}

View File

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