[PR #3636] [MERGED] feat(cli): support collection level authorization and headers #4464

Closed
opened 2026-03-17 01:59:51 +03:00 by kerem · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/hoppscotch/hoppscotch/pull/3636
Author: @jamesgeorge007
Created: 12/8/2023
Status: Merged
Merged: 12/14/2023
Merged by: @AndrewBastin

Base: release/2023.12.0Head: feat/coll-root-auth-headers-cli


📝 Commits (4)

  • ce51772 feat: support collection level headers and authorization in CLI
  • 649e0d1 fix: ensure child headers take precedence over parent headers
  • 1d697f0 test: increase coverage
  • 32bb7f8 chore: cleanup

📊 Changes

5 files changed (+373 additions, -104 deletions)

View changed files

📝 packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts (+52 -43)
packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json (+221 -0)
📝 packages/hoppscotch-cli/src/__tests__/utils.ts (+11 -4)
📝 packages/hoppscotch-cli/src/utils/collections.ts (+57 -41)
📝 packages/hoppscotch-cli/src/utils/request.ts (+32 -16)

📄 Description

Description

This PR includes the necessary changes to the CLI for processing the updated export format with authorization and headers set at the collection level.

  • For auth fields for folders/requests, the value at the current level is updated to be based on the immediate parent.
  • For header fields, the value at the current level is extended based on the immediate parent. If there are header entries with keys under the same name, the value set at the child level takes precedence.

The test suite has been updated with a relevant test case to verify the new behavior. Along with that, the execAsync helper function has been modified to maintain the CLI path locally and construct the command structure based on the received arguments, and renamed to runCLI.

Depends on #3505.

Closes HFE-295.

Steps to verify

Given below is a comprehensive sample to verify the behavior involving multiple auth types and headers. For a relatively simple example, please refer to the test suite.

  • Spin up an Express.js based server as below. Initialize package.json with npm init -y and add express as a dependency. Start the server with node index.js. Optionally, add it as a start script.

    const express = require('express');
    
    const app = express();
    const port = process.env.PORT || 4000;
    
    // Middleware to check for Basic Auth credentials in headers
    const basicAuth = (req, res, next) => {
      const authHeader = req.headers.authorization;
      const authActiveHeader = req.headers['auth-active']
      const authTypeHeader = req.headers['auth-type'];
    
      if (authActiveHeader !== 'true' ||  authTypeHeader !== 'basic-auth' || !authHeader || !authHeader.startsWith('Basic ')) {
        return res.status(401).json({ error: 'Unauthorized', headers: req.headers });
      }
    
      const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf-8');
      const [username, password] = credentials.split(':');
    
      if (username !== 'username' || password !== 'password') {
        return res.status(401).json({ error: 'Invalid username/password', headers: req.headers });
      }
    
      next();
    };
    
    // Middleware to check for API Key in the request header
    const apiKeyAuth = (req, res, next) => {
      const apiKey = req.headers['key'];
      const authActiveHeader = req.headers['auth-active']
      const authTypeHeader = req.headers['auth-type'];
    
      if (authActiveHeader !== 'true' || authTypeHeader !== 'api-key' || !apiKey || apiKey !== 'test-key') {
        return res.status(401).json({ error: 'Unauthorized', headers: req.headers });
      }
    
      next();
    };
    
    // Middleware to check for Bearer Token in the Authorization header
    const bearerTokenAuth = (req, res, next) => {
      const authHeader = req.headers.authorization;
      const authActiveHeader = req.headers['auth-active']
      const authTypeHeader = req.headers['auth-type'];
    
      if (authActiveHeader !== 'true'  || authTypeHeader !== 'bearer-token' || !authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'Unauthorized', headers: req.headers });
      }
    
      const token = authHeader.split(' ')[1];
      if (token !== 'test-token') {
        return res.status(401).json({ error: 'Invalid bearer token', headers: req.headers });
      }
    
      next();
    };
    
    const publicEndPointMiddleware = (req, res, next) => {
      const authActiveHeader = req.headers['auth-active']
      const authTypeHeader = req.headers['auth-type'];
    
      if (authActiveHeader === 'false' && !authTypeHeader) {
        return next();
      }
    
      return res.status(401).json({ error: 'Public endpoint should not require auth headers' });
    }
    
    // Endpoint with Basic Auth
    app.get('/basic-auth', basicAuth, (req, res) => {
      res.json({ message: 'Basic Auth endpoint accessed successfully!', headers: req.headers });
    });
    
    // Endpoint with API key Auth
    app.get('/api-key', apiKeyAuth, (req, res) => {
      res.json({ message: 'API key endpoint accessed successfully!', headers: req.headers });
    });
    
    // Endpoint with Bearer Token Auth
    app.get('/bearer-token', bearerTokenAuth, (req, res) => {
      res.json({ message: 'Bearer token endpoint accessed successfully!', headers: req.headers });
    });
    
    // Endpoint accessible without any auth
    app.get('/public', publicEndPointMiddleware, (req, res) => {
      res.json({ message: 'Public endpoint accessed successfully!', headers: req.headers });
    });
    
    // Start the server
    app.listen(port, () => {
      console.log(`Server is running on port ${port}`);
    });
    
  • Grab the following collection JSON contents and name it collection.json.

{
  "v": 1,
  "name": "Collection level headers and authorization",
  "folders": [
    {
      "v": 1,
      "name": "request-types",
      "folders": [
        {
          "v": 1,
          "name": "public-endpoint",
          "folders": [],
          "requests": [
            {
              "v": "1",
              "endpoint": "http://localhost:4000/public",
              "name": "non-auth-request",
              "params": [],
              "headers": [],
              "method": "GET",
              "auth": {
                "authType": "inherit",
                "authActive": true
              },
              "preRequestScript": "",
              "testScript": "pw.test(\"Status code is 200\", ()=> {\n    pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> {  \n  pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"false\");\n  pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"\");\n});",
              "body": {
                "contentType": null,
                "body": null
              },
              "id": "clpttpdq00003qp16kut6doqv"
            }
          ],
          "auth": {
            "authType": "none",
            "authActive": true
          },
          "headers": [
            {
              "active": true,
              "key": "auth-active",
              "value": "false"
            },
            {
              "active": true,
              "key": "auth-type",
              "value": ""
            }
          ]
        },
        {
          "v": 1,
          "name": "api-key-auth-headers",
          "folders": [],
          "requests": [
            {
              "v": "1",
              "endpoint": "http://localhost:4000/api-key",
              "name": "api-key-request",
              "params": [],
              "headers": [],
              "method": "GET",
              "auth": {
                "authType": "inherit",
                "authActive": true
              },
              "preRequestScript": "",
              "testScript": "pw.test(\"Status code is 200\", ()=> {\n    pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> {  \n  pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n  pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"api-key\");\n  pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});",
              "body": {
                "contentType": null,
                "body": null
              }
            }
          ],
          "auth": {
            "authType": "api-key",
            "authActive": true,
            "key": "key",
            "value": "test-key",
            "addTo": "Headers"
          },
          "headers": [
            {
              "active": true,
              "key": "auth-type",
              "value": "api-key"
            }
          ]
        },
        {
          "v": 1,
          "name": "bearer-token",
          "folders": [],
          "requests": [
            {
              "v": "1",
              "endpoint": "http://localhost:4000/bearer-token",
              "name": "bearer-token-request",
              "params": [],
              "headers": [],
              "method": "GET",
              "auth": {
                "authType": "inherit",
                "authActive": true
              },
              "preRequestScript": "",
              "testScript": "pw.test(\"Status code is 200\", ()=> {\n    pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> {  \n  pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n  pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"bearer-token\");\n  pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer test-token\");\n});",
              "body": {
                "contentType": null,
                "body": null
              }
            }
          ],
          "auth": {
            "authType": "bearer",
            "authActive": true,
            "token": "test-token"
          },
          "headers": [
            {
              "active": true,
              "key": "auth-type",
              "value": "bearer-token"
            }
          ]
        }
      ],
      "requests": [
        {
          "v": "1",
          "endpoint": "http://localhost:4000/basic-auth",
          "name": "basic-auth-request",
          "params": [],
          "headers": [],
          "method": "GET",
          "auth": {
            "authType": "inherit",
            "authActive": true
          },
          "preRequestScript": "",
          "testScript": "pw.test(\"Status code is 200\", ()=> {\n    pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the root collection\", ()=> {  \n  pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n  pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n  pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"basic-auth\");\n});",
          "body": {
            "contentType": null,
            "body": null
          },
          "id": "clpttpdq00003qp16kut6doqv"
        }
      ],
      "auth": {
        "authType": "inherit",
        "authActive": true
      },
      "headers": []
    }
  ],
  "requests": [
    {
      "v": "1",
      "endpoint": "http://localhost:4000/basic-auth",
      "name": "basic-auth-top-level-request",
      "params": [],
      "headers": [],
      "method": "GET",
      "auth": {
        "authType": "inherit",
        "authActive": true,
        "addTo": "Headers",
        "key": "key",
        "value": "value"
      },
      "preRequestScript": "",
      "testScript": "pw.test(\"Status code is 200\", ()=> {\n    pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the root collection\", ()=> {  \n  pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n  pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n  pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"basic-auth\");\n});",
      "body": {
        "contentType": null,
        "body": null
      },
      "id": "clpttpdq00003qp16kut6doqv"
    }
  ],
  "headers": [
    {
      "active": true,
      "key": "auth-active",
      "value": "true"
    },
    {
      "active": true,
      "key": "auth-type",
      "value": "basic-auth"
    }
  ],
  "auth": {
    "authType": "basic",
    "authActive": true,
    "token": "BearerToken",
    "username": "username",
    "password": "password"
  }
}
  • Navigate to the hoppscotch-cli package path, invoke the CLI supplying the above collection contents with

      node bin/hopp test collection.json
    
  • Relevant tests are added to the above collection file under testScript to assert the expected results at each level. Observe all the tests pass

Checks

  • My pull request adheres to the code style of this project
  • All the tests have passed

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/hoppscotch/hoppscotch/pull/3636 **Author:** [@jamesgeorge007](https://github.com/jamesgeorge007) **Created:** 12/8/2023 **Status:** ✅ Merged **Merged:** 12/14/2023 **Merged by:** [@AndrewBastin](https://github.com/AndrewBastin) **Base:** `release/2023.12.0` ← **Head:** `feat/coll-root-auth-headers-cli` --- ### 📝 Commits (4) - [`ce51772`](https://github.com/hoppscotch/hoppscotch/commit/ce517726959b374d8d86fcbb2f16647b680e00a3) feat: support collection level headers and authorization in CLI - [`649e0d1`](https://github.com/hoppscotch/hoppscotch/commit/649e0d14e97c7a95e501f59742e5d087b00de0f9) fix: ensure child headers take precedence over parent headers - [`1d697f0`](https://github.com/hoppscotch/hoppscotch/commit/1d697f0c3d7bb5af73d2765876bc56f55e69f2e1) test: increase coverage - [`32bb7f8`](https://github.com/hoppscotch/hoppscotch/commit/32bb7f8659765e69489fc730f608f18a38d10962) chore: cleanup ### 📊 Changes **5 files changed** (+373 additions, -104 deletions) <details> <summary>View changed files</summary> 📝 `packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts` (+52 -43) ➕ `packages/hoppscotch-cli/src/__tests__/samples/collection-level-headers-auth.json` (+221 -0) 📝 `packages/hoppscotch-cli/src/__tests__/utils.ts` (+11 -4) 📝 `packages/hoppscotch-cli/src/utils/collections.ts` (+57 -41) 📝 `packages/hoppscotch-cli/src/utils/request.ts` (+32 -16) </details> ### 📄 Description ### Description This PR includes the necessary changes to the CLI for processing the updated export format with authorization and headers set at the collection level. - For `auth` fields for folders/requests, the value at the current level is updated to be based on the immediate parent. - For `header` fields, the value at the current level is extended based on the immediate parent. If there are header entries with keys under the same name, the value set at the child level takes precedence. > The test suite has been updated with a relevant test case to verify the new behavior. Along with that, the `execAsync` helper function has been modified to maintain the CLI path locally and construct the command structure based on the received arguments, and renamed to `runCLI`. Depends on #3505. Closes HFE-295. ### Steps to verify Given below is a comprehensive sample to verify the behavior involving multiple auth types and headers. For a relatively simple example, please refer to the [test suite](https://github.com/hoppscotch/hoppscotch/blob/828d771da9e60f2587a6a86f3cc48aa8c08e7b59/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts#L76-L82). - Spin up an `Express.js` based server as below. Initialize `package.json` with `npm init -y` and add `express` as a dependency. Start the server with `node index.js`. Optionally, add it as a `start` script. ```js const express = require('express'); const app = express(); const port = process.env.PORT || 4000; // Middleware to check for Basic Auth credentials in headers const basicAuth = (req, res, next) => { const authHeader = req.headers.authorization; const authActiveHeader = req.headers['auth-active'] const authTypeHeader = req.headers['auth-type']; if (authActiveHeader !== 'true' || authTypeHeader !== 'basic-auth' || !authHeader || !authHeader.startsWith('Basic ')) { return res.status(401).json({ error: 'Unauthorized', headers: req.headers }); } const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf-8'); const [username, password] = credentials.split(':'); if (username !== 'username' || password !== 'password') { return res.status(401).json({ error: 'Invalid username/password', headers: req.headers }); } next(); }; // Middleware to check for API Key in the request header const apiKeyAuth = (req, res, next) => { const apiKey = req.headers['key']; const authActiveHeader = req.headers['auth-active'] const authTypeHeader = req.headers['auth-type']; if (authActiveHeader !== 'true' || authTypeHeader !== 'api-key' || !apiKey || apiKey !== 'test-key') { return res.status(401).json({ error: 'Unauthorized', headers: req.headers }); } next(); }; // Middleware to check for Bearer Token in the Authorization header const bearerTokenAuth = (req, res, next) => { const authHeader = req.headers.authorization; const authActiveHeader = req.headers['auth-active'] const authTypeHeader = req.headers['auth-type']; if (authActiveHeader !== 'true' || authTypeHeader !== 'bearer-token' || !authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Unauthorized', headers: req.headers }); } const token = authHeader.split(' ')[1]; if (token !== 'test-token') { return res.status(401).json({ error: 'Invalid bearer token', headers: req.headers }); } next(); }; const publicEndPointMiddleware = (req, res, next) => { const authActiveHeader = req.headers['auth-active'] const authTypeHeader = req.headers['auth-type']; if (authActiveHeader === 'false' && !authTypeHeader) { return next(); } return res.status(401).json({ error: 'Public endpoint should not require auth headers' }); } // Endpoint with Basic Auth app.get('/basic-auth', basicAuth, (req, res) => { res.json({ message: 'Basic Auth endpoint accessed successfully!', headers: req.headers }); }); // Endpoint with API key Auth app.get('/api-key', apiKeyAuth, (req, res) => { res.json({ message: 'API key endpoint accessed successfully!', headers: req.headers }); }); // Endpoint with Bearer Token Auth app.get('/bearer-token', bearerTokenAuth, (req, res) => { res.json({ message: 'Bearer token endpoint accessed successfully!', headers: req.headers }); }); // Endpoint accessible without any auth app.get('/public', publicEndPointMiddleware, (req, res) => { res.json({ message: 'Public endpoint accessed successfully!', headers: req.headers }); }); // Start the server app.listen(port, () => { console.log(`Server is running on port ${port}`); }); ``` - Grab the following collection JSON contents and name it `collection.json`. ```json { "v": 1, "name": "Collection level headers and authorization", "folders": [ { "v": 1, "name": "request-types", "folders": [ { "v": 1, "name": "public-endpoint", "folders": [], "requests": [ { "v": "1", "endpoint": "http://localhost:4000/public", "name": "non-auth-request", "params": [], "headers": [], "method": "GET", "auth": { "authType": "inherit", "authActive": true }, "preRequestScript": "", "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> { \n pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"false\");\n pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"\");\n});", "body": { "contentType": null, "body": null }, "id": "clpttpdq00003qp16kut6doqv" } ], "auth": { "authType": "none", "authActive": true }, "headers": [ { "active": true, "key": "auth-active", "value": "false" }, { "active": true, "key": "auth-type", "value": "" } ] }, { "v": 1, "name": "api-key-auth-headers", "folders": [], "requests": [ { "v": "1", "endpoint": "http://localhost:4000/api-key", "name": "api-key-request", "params": [], "headers": [], "method": "GET", "auth": { "authType": "inherit", "authActive": true }, "preRequestScript": "", "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> { \n pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"api-key\");\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});", "body": { "contentType": null, "body": null } } ], "auth": { "authType": "api-key", "authActive": true, "key": "key", "value": "test-key", "addTo": "Headers" }, "headers": [ { "active": true, "key": "auth-type", "value": "api-key" } ] }, { "v": 1, "name": "bearer-token", "folders": [], "requests": [ { "v": "1", "endpoint": "http://localhost:4000/bearer-token", "name": "bearer-token-request", "params": [], "headers": [], "method": "GET", "auth": { "authType": "inherit", "authActive": true }, "preRequestScript": "", "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the parent folder\", ()=> { \n pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"bearer-token\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer test-token\");\n});", "body": { "contentType": null, "body": null } } ], "auth": { "authType": "bearer", "authActive": true, "token": "test-token" }, "headers": [ { "active": true, "key": "auth-type", "value": "bearer-token" } ] } ], "requests": [ { "v": "1", "endpoint": "http://localhost:4000/basic-auth", "name": "basic-auth-request", "params": [], "headers": [], "method": "GET", "auth": { "authType": "inherit", "authActive": true }, "preRequestScript": "", "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the root collection\", ()=> { \n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"basic-auth\");\n});", "body": { "contentType": null, "body": null }, "id": "clpttpdq00003qp16kut6doqv" } ], "auth": { "authType": "inherit", "authActive": true }, "headers": [] } ], "requests": [ { "v": "1", "endpoint": "http://localhost:4000/basic-auth", "name": "basic-auth-top-level-request", "params": [], "headers": [], "method": "GET", "auth": { "authType": "inherit", "authActive": true, "addTo": "Headers", "key": "key", "value": "value" }, "preRequestScript": "", "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Correctly inherits authorization and headers from the root collection\", ()=> { \n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n pw.expect(pw.response.body.headers[\"auth-active\"]).toBe(\"true\");\n pw.expect(pw.response.body.headers[\"auth-type\"]).toBe(\"basic-auth\");\n});", "body": { "contentType": null, "body": null }, "id": "clpttpdq00003qp16kut6doqv" } ], "headers": [ { "active": true, "key": "auth-active", "value": "true" }, { "active": true, "key": "auth-type", "value": "basic-auth" } ], "auth": { "authType": "basic", "authActive": true, "token": "BearerToken", "username": "username", "password": "password" } } ``` - Navigate to the `hoppscotch-cli` package path, invoke the CLI supplying the above collection contents with ``` node bin/hopp test collection.json ``` - Relevant tests are added to the above collection file under `testScript` to assert the expected results at each level. Observe all the tests pass ✅ ### Checks - [x] My pull request adheres to the code style of this project - [x] All the tests have passed --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
kerem 2026-03-17 01:59:51 +03:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/hoppscotch#4464
No description provided.