feat: secret variables in environments (#3779)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user