feat(cli): support collection level authorization and headers (#3636)

This commit is contained in:
James George
2023-12-14 12:43:22 +05:30
committed by GitHub
parent c47e2e7767
commit 9bc81a6d67
5 changed files with 373 additions and 104 deletions

View File

@@ -1,63 +1,64 @@
import { ExecException } from "child_process"; import { ExecException } from "child_process";
import { HoppErrorCode } from "../../types/errors"; import { HoppErrorCode } from "../../types/errors";
import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils"; import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
describe("Test 'hopp test <file>' command:", () => { describe("Test 'hopp test <file>' command:", () => {
test("No collection file path provided.", async () => { test("No collection file path provided.", async () => {
const cmd = `node ./bin/hopp test`; const args = "test";
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });
test("Collection file not found.", async () => { test("Collection file not found.", async () => {
const cmd = `node ./bin/hopp test notfound.json`; const args = "test notfound.json";
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND"); expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
}); });
test("Collection file is invalid JSON.", async () => { test("Collection file is invalid JSON.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath( const args = `test ${getTestJsonFilePath(
"malformed-collection.json" "malformed-collection.json"
)}`; )}`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR"); expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
}); });
test("Malformed collection file.", async () => { test("Malformed collection file.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath( const args = `test ${getTestJsonFilePath(
"malformed-collection2.json" "malformed-collection2.json"
)}`; )}`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION"); expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
}); });
test("Invalid arguement.", async () => { test("Invalid arguement.", async () => {
const cmd = `node ./bin/hopp invalid-arg`; const args = "invalid-arg";
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });
test("Collection file not JSON type.", async () => { test("Collection file not JSON type.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`; const args = `test ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE"); expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
}); });
test("Some errors occured (exit code 1).", async () => { test("Some errors occured (exit code 1).", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("fails.json")}`; const args = `test ${getTestJsonFilePath("fails.json")}`;
const { error } = await execAsync(cmd); const { error } = await runCLI(args);
expect(error).not.toBeNull(); expect(error).not.toBeNull();
expect(error).toMatchObject(<ExecException>{ expect(error).toMatchObject(<ExecException>{
@@ -66,75 +67,83 @@ describe("Test 'hopp test <file>' command:", () => {
}); });
test("No errors occured (exit code 0).", async () => { test("No errors occured (exit code 0).", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("passes.json")}`; const args = `test ${getTestJsonFilePath("passes.json")}`;
const { error } = await execAsync(cmd); const { error } = await runCLI(args);
expect(error).toBeNull(); expect(error).toBeNull();
}); });
test("Supports inheriting headers and authorization set at the root collection", async () => {
const args = `test ${getTestJsonFilePath("collection-level-headers-auth.json")}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
})
}); });
describe("Test 'hopp test <file> --env <file>' command:", () => { describe("Test 'hopp test <file> --env <file>' command:", () => {
const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath( const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json" "passes.json"
)}`; )}`;
test("No env file path provided.", async () => { test("No env file path provided.", async () => {
const cmd = `${VALID_TEST_CMD} --env`; const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });
test("ENV file not JSON type.", async () => { test("ENV file not JSON type.", async () => {
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`; const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE"); expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
}); });
test("ENV file not found.", async () => { test("ENV file not found.", async () => {
const cmd = `${VALID_TEST_CMD} --env notfound.json`; const args = `${VALID_TEST_ARGS} --env notfound.json`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND"); expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
}); });
test("No errors occured (exit code 0).", async () => { test("No errors occured (exit code 0).", async () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json"); const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json"); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`; const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error, stdout } = await execAsync(cmd);
const { error } = await runCLI(args);
expect(error).toBeNull(); expect(error).toBeNull();
}); });
}); });
describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => { describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath( const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json" "passes.json"
)}`; )}`;
test("No value passed to delay flag.", async () => { test("No value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay`; const args = `${VALID_TEST_ARGS} --delay`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });
test("Invalid value passed to delay flag.", async () => { test("Invalid value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`; const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
const { stderr } = await execAsync(cmd); const { stderr } = await runCLI(args);
const out = getErrorCode(stderr); const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });
test("Valid value passed to delay flag.", async () => { test("Valid value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay 1`; const args = `${VALID_TEST_ARGS} --delay 1`;
const { error } = await execAsync(cmd); const { error } = await runCLI(args);
expect(error).toBeNull(); expect(error).toBeNull();
}); });

View File

@@ -0,0 +1,221 @@
[
{
"v": 1,
"name": "CollectionA",
"folders": [
{
"v": 1,
"name": "FolderA",
"folders": [
{
"v": 1,
"name": "FolderB",
"folders": [
{
"v": 1,
"name": "FolderC",
"folders": [],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestD",
"params": [],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Overriden at RequestD"
}
],
"method": "GET",
"auth": {
"authType": "basic",
"authActive": true,
"username": "username",
"password": "password"
},
"preRequestScript": "",
"testScript": "pw.test(\"Overrides auth and headers set at the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at RequestD\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n});",
"body": {
"contentType": null,
"body": null
}
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestC",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at FolderB\");\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});",
"body": {
"contentType": null,
"body": null
}
}
],
"auth": {
"authType": "api-key",
"authActive": true,
"addTo": "Headers",
"key": "key",
"value": "test-key"
},
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Overriden at FolderB"
}
]
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Set at root collection"
}
],
"auth": {
"authType": "bearer",
"authActive": true,
"token": "BearerToken"
}
},
{
"v": 1,
"name": "CollectionB",
"folders": [
{
"v": 1,
"name": "FolderA",
"folders": [],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Set at root collection"
}
],
"auth": {
"authType": "bearer",
"authActive": true,
"token": "BearerToken"
}
}
]

View File

@@ -1,10 +1,17 @@
import { exec } from "child_process"; import { exec } from "child_process";
import { resolve } from "path";
import { ExecResponse } from "./types"; import { ExecResponse } from "./types";
export const execAsync = (command: string): Promise<ExecResponse> => export const runCLI = (args: string): Promise<ExecResponse> =>
new Promise((resolve) => {
const CLI_PATH = resolve(__dirname, "../../bin/hopp");
const command = `node ${CLI_PATH} ${args}`
return new Promise((resolve) =>
exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr })) exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
); );
}
export const trimAnsi = (target: string) => { export const trimAnsi = (target: string) => {
const ansiRegex = const ansiRegex =

View File

@@ -1,21 +1,23 @@
import * as A from "fp-ts/Array"; import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { pipe } from "fp-ts/function";
import { bold } from "chalk"; import { bold } from "chalk";
import { log } from "console"; import { log } from "console";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import round from "lodash/round"; import round from "lodash/round";
import { HoppCollection } from "@hoppscotch/data";
import { CollectionRunnerParam } from "../types/collections";
import { import {
HoppEnvs,
CollectionStack, CollectionStack,
RequestReport, HoppEnvs,
ProcessRequestParams, ProcessRequestParams,
RequestReport,
} from "../types/request"; } from "../types/request";
import { import {
getRequestMetrics, PreRequestMetrics,
preProcessRequest, RequestMetrics,
processRequest, TestMetrics,
} from "./request"; } from "../types/response";
import { exceptionColors } from "./getters"; import { DEFAULT_DURATION_PRECISION } from "./constants";
import { import {
printErrorsReport, printErrorsReport,
printFailedTestsReport, printFailedTestsReport,
@@ -23,15 +25,14 @@ import {
printRequestsMetrics, printRequestsMetrics,
printTestsMetrics, printTestsMetrics,
} from "./display"; } from "./display";
import { import { exceptionColors } from "./getters";
PreRequestMetrics,
RequestMetrics,
TestMetrics,
} from "../types/response";
import { getTestMetrics } from "./test";
import { DEFAULT_DURATION_PRECISION } from "./constants";
import { getPreRequestMetrics } from "./pre-request"; import { getPreRequestMetrics } from "./pre-request";
import { CollectionRunnerParam } from "../types/collections"; import {
getRequestMetrics,
preProcessRequest,
processRequest,
} from "./request";
import { getTestMetrics } from "./test";
const { WARN, FAIL } = exceptionColors; const { WARN, FAIL } = exceptionColors;
@@ -57,7 +58,7 @@ export const collectionsRunner = async (
// Processing each request in collection // Processing each request in collection
for (const request of collection.requests) { for (const request of collection.requests) {
const _request = preProcessRequest(request); const _request = preProcessRequest(request as HoppRESTRequest, collection);
const requestPath = `${path}/${_request.name}`; const requestPath = `${path}/${_request.name}`;
const processRequestParams: ProcessRequestParams = { const processRequestParams: ProcessRequestParams = {
path: requestPath, path: requestPath,
@@ -84,9 +85,24 @@ export const collectionsRunner = async (
// Pushing remaining folders realted collection to stack. // Pushing remaining folders realted collection to stack.
for (const folder of collection.folders) { for (const folder of collection.folders) {
const updatedFolder: HoppCollection = { ...folder }
if (updatedFolder.auth?.authType === "inherit") {
updatedFolder.auth = collection.auth;
}
if (collection.headers?.length) {
// Filter out header entries present in the parent collection under the same name
// This ensures the folder headers take precedence over the collection headers
const filteredHeaders = collection.headers.filter((collectionHeaderEntries) => {
return !updatedFolder.headers.some((folderHeaderEntries) => folderHeaderEntries.key === collectionHeaderEntries.key)
})
updatedFolder.headers.push(...filteredHeaders);
}
collectionStack.push({ collectionStack.push({
path: `${path}/${folder.name}`, path: `${path}/${updatedFolder.name}`,
collection: folder, collection: updatedFolder,
}); });
} }
} }

View File

@@ -1,31 +1,31 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import axios, { Method } from "axios"; import axios, { Method } from "axios";
import { URL } from "url";
import * as S from "fp-ts/string";
import * as A from "fp-ts/Array"; import * as A from "fp-ts/Array";
import * as T from "fp-ts/Task";
import * as E from "fp-ts/Either"; import * as E from "fp-ts/Either";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither"; import * as TE from "fp-ts/TaskEither";
import { HoppRESTRequest } from "@hoppscotch/data"; import { pipe } from "fp-ts/function";
import { responseErrors } from "./constants"; import * as S from "fp-ts/string";
import { getDurationInSeconds, getMetaDataPairs } from "./getters"; import { hrtime } from "process";
import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test"; import { URL } from "url";
import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request"; import { EffectiveHoppRESTRequest, RequestConfig } from "../interfaces/request";
import { RequestRunnerResponse } from "../interfaces/response"; import { RequestRunnerResponse } from "../interfaces/response";
import { preRequestScriptRunner } from "./pre-request"; import { HoppCLIError, error } from "../types/errors";
import { import {
HoppEnvs, HoppEnvs,
ProcessRequestParams, ProcessRequestParams,
RequestReport, RequestReport,
} from "../types/request"; } from "../types/request";
import { RequestMetrics } from "../types/response";
import { responseErrors } from "./constants";
import { import {
printPreRequestRunner, printPreRequestRunner,
printRequestRunner, printRequestRunner,
printTestRunner, printTestRunner,
} from "./display"; } from "./display";
import { error, HoppCLIError } from "../types/errors"; import { getDurationInSeconds, getMetaDataPairs } from "./getters";
import { hrtime } from "process"; import { preRequestScriptRunner } from "./pre-request";
import { RequestMetrics } from "../types/response"; import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
import { pipe } from "fp-ts/function";
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported // !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
@@ -309,9 +309,12 @@ export const processRequest =
* @returns Updated request object free of invalid/missing data. * @returns Updated request object free of invalid/missing data.
*/ */
export const preProcessRequest = ( export const preProcessRequest = (
request: HoppRESTRequest request: HoppRESTRequest,
collection: HoppCollection,
): HoppRESTRequest => { ): HoppRESTRequest => {
const tempRequest = Object.assign({}, request); const tempRequest = Object.assign({}, request);
const { headers: parentHeaders, auth: parentAuth } = collection;
if (!tempRequest.v) { if (!tempRequest.v) {
tempRequest.v = "1"; tempRequest.v = "1";
} }
@@ -327,18 +330,31 @@ export const preProcessRequest = (
if (!tempRequest.params) { if (!tempRequest.params) {
tempRequest.params = []; tempRequest.params = [];
} }
if (!tempRequest.headers) {
if (parentHeaders?.length) {
// Filter out header entries present in the parent (folder/collection) under the same name
// This ensures the child headers take precedence over the parent headers
const filteredEntries = parentHeaders.filter((parentHeaderEntries) => {
return !tempRequest.headers.some((reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key)
})
tempRequest.headers.push(...filteredEntries);
} else if (!tempRequest.headers) {
tempRequest.headers = []; tempRequest.headers = [];
} }
if (!tempRequest.preRequestScript) { if (!tempRequest.preRequestScript) {
tempRequest.preRequestScript = ""; tempRequest.preRequestScript = "";
} }
if (!tempRequest.testScript) { if (!tempRequest.testScript) {
tempRequest.testScript = ""; tempRequest.testScript = "";
} }
if (!tempRequest.auth) {
if (tempRequest.auth?.authType === "inherit") {
tempRequest.auth = parentAuth;
} else if (!tempRequest.auth) {
tempRequest.auth = { authActive: false, authType: "none" }; tempRequest.auth = { authActive: false, authType: "none" };
} }
if (!tempRequest.body) { if (!tempRequest.body) {
tempRequest.body = { contentType: null, body: null }; tempRequest.body = { contentType: null, body: null };
} }