feat: inspections (#3213)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Nivedin
2023-08-18 01:37:21 +05:30
committed by GitHub
parent b55970cc7a
commit f21ed30e10
20 changed files with 1406 additions and 19 deletions

View File

@@ -0,0 +1,158 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { EnvironmentInspectorService } from "../environment.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
vi.mock("~/newstore/environments", () => ({
__esModule: true,
getAggregateEnvs: () => [{ key: "EXISTING_ENV_VAR", value: "test_value" }],
}))
describe("EnvironmentInspectorService", () => {
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const envInspector = container.bind(EnvironmentInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(envInspector)
})
describe("getInspectorFor", () => {
it("should return an inspector result when the URL contains undefined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "<<UNDEFINED_ENV_VAR>>",
}
const result = envInspector.getInspectorFor(req)
expect(result).toContainEqual(
expect.objectContaining({
id: "environment",
isApplicable: true,
text: {
type: "text",
text: "inspections.environment.not_found",
},
})
)
})
it("should not return an inspector result when the URL contains defined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "<<EXISTING_ENV_VAR>>",
}
const result = envInspector.getInspectorFor(req)
expect(result).toHaveLength(0)
})
it("should return an inspector result when the headers contain undefined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [
{ key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true },
],
}
const result = envInspector.getInspectorFor(req)
expect(result).toContainEqual(
expect.objectContaining({
id: "environment",
isApplicable: true,
text: {
type: "text",
text: "inspections.environment.not_found",
},
})
)
})
it("should not return an inspector result when the headers contain defined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
],
}
const result = envInspector.getInspectorFor(req)
expect(result).toHaveLength(0)
})
it("should return an inspector result when the params contain undefined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
params: [
{ key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true },
],
}
const result = envInspector.getInspectorFor(req)
expect(result).toContainEqual(
expect.objectContaining({
id: "environment",
isApplicable: true,
text: {
type: "text",
text: "inspections.environment.not_found",
},
})
)
})
it("should not return an inspector result when the params contain defined environment variables", () => {
const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [],
params: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
],
}
const result = envInspector.getInspectorFor(req)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,61 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { HeaderInspectorService } from "../header.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
describe("HeaderInspectorService", () => {
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const headerInspector = container.bind(HeaderInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(headerInspector)
})
describe("getInspectorFor", () => {
it("should return an inspector result when headers contain cookies", () => {
const container = new TestContainer()
const headerInspector = container.bind(HeaderInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }],
}
const result = headerInspector.getInspectorFor(req)
expect(result).toContainEqual(
expect.objectContaining({ id: "header", isApplicable: true })
)
})
it("should return an empty array when headers do not contain cookies", () => {
const container = new TestContainer()
const headerInspector = container.bind(HeaderInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Authorization", value: "Bearer abcd", active: true }],
}
const result = headerInspector.getInspectorFor(req)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,151 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ResponseInspectorService } from "../response.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
describe("ResponseInspectorService", () => {
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const responseInspector = container.bind(ResponseInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(responseInspector)
})
describe("getInspectorFor", () => {
it("should return an empty array when response is undefined", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const result = responseInspector.getInspectorFor(req, undefined)
expect(result).toHaveLength(0)
})
it("should return an inspector result when response type is not success or status code is not 200", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "network_fail", statusCode: 400 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({ id: "url", isApplicable: true })
)
})
it("should handle network_fail responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "network_fail", statusCode: 500 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.network_error" },
})
)
})
it("should handle fail responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "fail", statusCode: 500 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.default_error" },
})
)
})
it("should handle 404 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 404 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.404_error" },
})
)
})
it("should handle 401 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 401 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.401_error" },
})
)
})
it("should handle successful responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 200 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,84 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { URLInspectorService } from "../url.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
describe("URLInspectorService", () => {
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const urlInspector = container.bind(URLInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(urlInspector)
})
describe("getInspectorFor", () => {
it("should return an inspector result when localhost is in URL and extension is not available", () => {
const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://localhost:8000/api/data",
}
const result = urlInspector.getInspectorFor(req)
expect(result).toContainEqual(
expect.objectContaining({ id: "url", isApplicable: true })
)
})
it("should not return an inspector result when localhost is not in URL", () => {
const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const result = urlInspector.getInspectorFor(req)
expect(result).toHaveLength(0)
})
it("should add the correct text to the results when extension is not installed", () => {
vi.mock("~/newstore/HoppExtension", async () => {
const { BehaviorSubject }: any = await vi.importActual("rxjs")
return {
__esModule: true,
extensionStatus$: new BehaviorSubject("waiting"),
}
})
const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://localhost:8000/api/data",
}
const result = urlInspector.getInspectorFor(req)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
text: { type: "text", text: "inspections.url.extension_not_installed" },
})
})
})
})

View File

@@ -0,0 +1,167 @@
import { getI18n } from "~/modules/i18n"
import {
InspectionService,
Inspector,
InspectorLocation,
InspectorResult,
} from ".."
import { Service } from "dioc"
import { Ref, markRaw, ref } from "vue"
import IconPlusCircle from "~icons/lucide/plus-circle"
import { HoppRESTRequest } from "@hoppscotch/data"
import { getAggregateEnvs } from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions"
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
const isENVInString = (str: string) => {
return HOPP_ENVIRONMENT_REGEX.test(str)
}
/**
* This inspector is responsible for inspecting the environment variables of a input.
* It checks if the environment variables are defined in the environment.
* It also provides an action to add the environment variable.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class EnvironmentInspectorService extends Service implements Inspector {
public static readonly ID = "ENVIRONMENT_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "environment"
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
/**
* 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
*/
private validateEnvironmentVariables = (
target: any[],
results: Ref<InspectorResult[]>,
locations: InspectorLocation
) => {
const env = getAggregateEnvs()
const envKeys = env.map((e) => e.key)
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)
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",
}
}
if (!envKeys.includes(formattedExEnv)) {
results.value.push({
id: "environment",
text: {
type: "text",
text: this.t("inspections.environment.not_found", {
environment: exEnv,
}),
},
icon: markRaw(IconPlusCircle),
action: {
text: this.t("inspections.environment.add_environment"),
apply: () => {
invokeAction("modals.environment.add", {
envName: "test",
variableName: formattedExEnv,
})
},
},
severity: 3,
isApplicable: true,
locations: itemLocation,
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
},
})
}
})
}
}
})
}
/**
* Returns the inspector results for the request
* It checks if any env is used in the request ie, url, headers, params
* and checks if the env is defined in the environment using the validateEnvironmentVariables function
* @param req The request to inspect
* @returns The inspector results
*/
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const results = ref<InspectorResult[]>([])
const headers = req.headers
const params = req.params
this.validateEnvironmentVariables([req.endpoint], results, {
type: "url",
})
const headerKeys = Object.values(headers).map((header) => header.key)
this.validateEnvironmentVariables(headerKeys, results, {
type: "header",
position: "key",
})
const headerValues = Object.values(headers).map((header) => header.value)
this.validateEnvironmentVariables(headerValues, results, {
type: "header",
position: "value",
})
const paramsKeys = Object.values(params).map((param) => param.key)
this.validateEnvironmentVariables(paramsKeys, results, {
type: "parameter",
position: "key",
})
const paramsValues = Object.values(params).map((param) => param.value)
this.validateEnvironmentVariables(paramsValues, results, {
type: "parameter",
position: "value",
})
return results.value
}
}

View File

@@ -0,0 +1,78 @@
import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { markRaw, ref } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
/**
* This inspector is responsible for inspecting the header of a request.
* It checks if the header contains cookies.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class HeaderInspectorService extends Service implements Inspector {
public static readonly ID = "HEADER_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "header"
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
/**
* Checks if the header contains cookies
* @param req The request to inspect
* @returns The inspector results
*/
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const results = ref<InspectorResult[]>([])
const cookiesCheck = (headerKey: string) => {
const cookieKeywords = ["Cookie", "Set-Cookie", "Cookie2", "Set-Cookie2"]
return cookieKeywords.includes(headerKey)
}
const headers = req.headers
const headerKeys = Object.values(headers).map((header) => header.key)
const isContainCookies = headerKeys.includes("Cookie")
if (isContainCookies) {
headerKeys.forEach((headerKey, index) => {
if (cookiesCheck(headerKey)) {
results.value.push({
id: "header",
icon: markRaw(IconAlertTriangle),
text: {
type: "text",
text: this.t("inspections.header.cookie"),
},
severity: 2,
isApplicable: true,
locations: {
type: "header",
position: "key",
key: headerKey,
index: index,
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
},
})
}
})
}
return results.value
}
}

View File

@@ -0,0 +1,73 @@
import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { markRaw, ref } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
/**
* This inspector is responsible for inspecting the response of a request.
* It checks if the response is successful and if it contains errors.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class ResponseInspectorService extends Service implements Inspector {
public static readonly ID = "RESPONSE_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "response"
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
getInspectorFor(
req: HoppRESTRequest,
res: HoppRESTResponse | undefined
): InspectorResult[] {
const results = ref<InspectorResult[]>([])
if (!res) return results.value
const hasErrors = res && (res.type !== "success" || res.statusCode !== 200)
let text
if (res.type === "network_fail") {
text = this.t("inspections.response.network_error")
} else if (res.type === "fail") {
text = this.t("inspections.response.default_error")
} else if (res.type === "success" && res.statusCode === 404) {
text = this.t("inspections.response.404_error")
} else if (res.type === "success" && res.statusCode === 401) {
text = this.t("inspections.response.401_error")
}
if (hasErrors && text) {
results.value.push({
id: "url",
icon: markRaw(IconAlertTriangle),
text: {
type: "text",
text: text,
},
severity: 2,
isApplicable: true,
locations: {
type: "response",
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
},
})
}
return results.value
}
}

View File

@@ -0,0 +1,96 @@
import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, markRaw, ref } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { useReadonlyStream } from "~/composables/stream"
import { extensionStatus$ } from "~/newstore/HoppExtension"
import { useSetting } from "~/composables/settings"
import { applySetting, toggleSetting } from "~/newstore/settings"
/**
* This inspector is responsible for inspecting the URL of a request.
* It checks if the URL contains localhost and if the extension is installed.
* It also provides an action to enable the extension.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class URLInspectorService extends Service implements Inspector {
public static readonly ID = "URL_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "url"
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
const isExtensionInstalled = computed(() => {
return currentExtensionStatus.value === "available"
})
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const results = ref<InspectorResult[]>([])
const url = req.endpoint
const isContainLocalhost = url.includes("localhost")
if (
isContainLocalhost &&
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
) {
let text
if (!isExtensionInstalled.value) {
if (currentExtensionStatus.value === "unknown-origin") {
text = this.t("inspections.url.extension_unknown_origin")
} else {
text = this.t("inspections.url.extension_not_installed")
}
} else if (!EXTENSIONS_ENABLED.value) {
text = this.t("inspections.url.extention_not_enabled")
} else {
text = this.t("inspections.url.localhost")
}
results.value.push({
id: "url",
icon: markRaw(IconAlertTriangle),
text: {
type: "text",
text: text,
},
action: {
text: this.t("inspections.url.extention_enable_action"),
apply: () => {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
},
},
severity: 2,
isApplicable: true,
locations: {
type: "url",
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
},
})
}
return results.value
}
}