Browse Source

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

James George 1 year ago
parent
commit
9bc81a6d67

+ 52 - 43
packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts

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

+ 221 - 0
packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json

@@ -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"
+    }
+  }
+]

+ 11 - 4
packages/hoppscotch-cli/src/__tests__/utils.ts

@@ -1,10 +1,17 @@
 import { exec } from "child_process";
+import { resolve } from "path";
+
 import { ExecResponse } from "./types";
 
-export const execAsync = (command: string): Promise<ExecResponse> =>
-  new Promise((resolve) =>
-    exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
-  );
+export const runCLI = (args: string): Promise<ExecResponse> =>
+  {
+    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 }))
+    );
+  }
 
 export const trimAnsi = (target: string) => {
   const ansiRegex =

+ 57 - 41
packages/hoppscotch-cli/src/utils/collections.ts

@@ -1,21 +1,23 @@
-import * as A from "fp-ts/Array";
-import { pipe } from "fp-ts/function";
+import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
 import { bold } from "chalk";
 import { log } from "console";
+import * as A from "fp-ts/Array";
+import { pipe } from "fp-ts/function";
 import round from "lodash/round";
-import { HoppCollection } from "@hoppscotch/data";
+
+import { CollectionRunnerParam } from "../types/collections";
 import {
-  HoppEnvs,
   CollectionStack,
-  RequestReport,
+  HoppEnvs,
   ProcessRequestParams,
+  RequestReport,
 } from "../types/request";
 import {
-  getRequestMetrics,
-  preProcessRequest,
-  processRequest,
-} from "./request";
-import { exceptionColors } from "./getters";
+  PreRequestMetrics,
+  RequestMetrics,
+  TestMetrics,
+} from "../types/response";
+import { DEFAULT_DURATION_PRECISION } from "./constants";
 import {
   printErrorsReport,
   printFailedTestsReport,
@@ -23,15 +25,14 @@ import {
   printRequestsMetrics,
   printTestsMetrics,
 } from "./display";
+import { exceptionColors } from "./getters";
+import { getPreRequestMetrics } from "./pre-request";
 import {
-  PreRequestMetrics,
-  RequestMetrics,
-  TestMetrics,
-} from "../types/response";
+  getRequestMetrics,
+  preProcessRequest,
+  processRequest,
+} from "./request";
 import { getTestMetrics } from "./test";
-import { DEFAULT_DURATION_PRECISION } from "./constants";
-import { getPreRequestMetrics } from "./pre-request";
-import { CollectionRunnerParam } from "../types/collections";
 
 const { WARN, FAIL } = exceptionColors;
 
@@ -55,19 +56,19 @@ export const collectionsRunner = async (
     // Pop out top-most collection from stack to be processed.
     const { collection, path } = <CollectionStack>collectionStack.pop();
 
-    // Processing each request in collection
-    for (const request of collection.requests) {
-      const _request = preProcessRequest(request);
-      const requestPath = `${path}/${_request.name}`;
-      const processRequestParams: ProcessRequestParams = {
-        path: requestPath,
-        request: _request,
-        envs,
-        delay,
-      };
+      // Processing each request in collection
+      for (const request of collection.requests) {
+        const _request = preProcessRequest(request as HoppRESTRequest, collection);
+        const requestPath = `${path}/${_request.name}`;
+        const processRequestParams: ProcessRequestParams = {
+          path: requestPath,
+          request: _request,
+          envs,
+          delay,
+        };
 
-      // Request processing initiated message.
-      log(WARN(`\nRunning: ${bold(requestPath)}`));
+        // Request processing initiated message.
+        log(WARN(`\nRunning: ${bold(requestPath)}`));
 
       // Processing current request.
       const result = await processRequest(processRequestParams)();
@@ -77,19 +78,34 @@ export const collectionsRunner = async (
       envs.global = global;
       envs.selected = selected;
 
-      // Storing current request's report.
-      const requestReport = result.report;
-      requestsReport.push(requestReport);
-    }
-
-    // Pushing remaining folders realted collection to stack.
-    for (const folder of collection.folders) {
-      collectionStack.push({
-        path: `${path}/${folder.name}`,
-        collection: folder,
-      });
+        // Storing current request's report.
+        const requestReport = result.report;
+        requestsReport.push(requestReport);
+      }
+
+      // Pushing remaining folders realted collection to stack.
+      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({
+          path: `${path}/${updatedFolder.name}`,
+          collection: updatedFolder,
+        });
+      }
     }
-  }
 
   return requestsReport;
 };

+ 32 - 16
packages/hoppscotch-cli/src/utils/request.ts

@@ -1,31 +1,31 @@
+import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
 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 T from "fp-ts/Task";
 import * as E from "fp-ts/Either";
+import * as T from "fp-ts/Task";
 import * as TE from "fp-ts/TaskEither";
-import { HoppRESTRequest } from "@hoppscotch/data";
-import { responseErrors } from "./constants";
-import { getDurationInSeconds, getMetaDataPairs } from "./getters";
-import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test";
-import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request";
+import { pipe } from "fp-ts/function";
+import * as S from "fp-ts/string";
+import { hrtime } from "process";
+import { URL } from "url";
+import { EffectiveHoppRESTRequest, RequestConfig } from "../interfaces/request";
 import { RequestRunnerResponse } from "../interfaces/response";
-import { preRequestScriptRunner } from "./pre-request";
+import { HoppCLIError, error } from "../types/errors";
 import {
   HoppEnvs,
   ProcessRequestParams,
   RequestReport,
 } from "../types/request";
+import { RequestMetrics } from "../types/response";
+import { responseErrors } from "./constants";
 import {
   printPreRequestRunner,
   printRequestRunner,
   printTestRunner,
 } from "./display";
-import { error, HoppCLIError } from "../types/errors";
-import { hrtime } from "process";
-import { RequestMetrics } from "../types/response";
-import { pipe } from "fp-ts/function";
+import { getDurationInSeconds, getMetaDataPairs } from "./getters";
+import { preRequestScriptRunner } from "./pre-request";
+import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
 
 // !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.
  */
 export const preProcessRequest = (
-  request: HoppRESTRequest
+  request: HoppRESTRequest,
+  collection: HoppCollection,
 ): HoppRESTRequest => {
   const tempRequest = Object.assign({}, request);
+  const { headers: parentHeaders, auth: parentAuth } = collection;
+
   if (!tempRequest.v) {
     tempRequest.v = "1";
   }
@@ -327,18 +330,31 @@ export const preProcessRequest = (
   if (!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 = [];
   }
+
   if (!tempRequest.preRequestScript) {
     tempRequest.preRequestScript = "";
   }
   if (!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" };
   }
+
   if (!tempRequest.body) {
     tempRequest.body = { contentType: null, body: null };
   }